diff --git a/tool/linter/licenseheader/main.go b/tool/linter/licenseheader/main.go index 33770db9..020aa0a8 100644 --- a/tool/linter/licenseheader/main.go +++ b/tool/linter/licenseheader/main.go @@ -19,11 +19,16 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" + "time" ) -const header = `// Copyright (c) 2025 Uber Technologies, Inc. -// +// licenseBody is everything after the copyright line. The copyright line itself +// carries a year, which is validated by pattern (any 4-digit year) rather than +// pinned to a single value — so files authored in earlier years keep passing +// while new files are stamped with the current year. +const licenseBody = `// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -36,6 +41,20 @@ const header = `// Copyright (c) 2025 Uber Technologies, Inc. // See the License for the specific language governing permissions and // limitations under the License.` +// copyrightLineRe matches the first line of the header with any 4-digit year. +var copyrightLineRe = regexp.MustCompile(`^// Copyright \(c\) \d{4} Uber Technologies, Inc\.$`) + +// copyrightLine returns the copyright line for a given year. +func copyrightLine(year int) string { + return fmt.Sprintf("// Copyright (c) %d Uber Technologies, Inc.", year) +} + +// header returns the full license header stamped with the given year, used when +// adding a header in -fix mode. +func header(year int) string { + return copyrightLine(year) + "\n" + licenseBody +} + func main() { fix := flag.Bool("fix", false, "add missing license headers in-place") check := flag.Bool("check", false, "check for missing license headers (default mode)") @@ -156,6 +175,7 @@ func isGeneratedFile(path string) bool { } // hasLicenseHeader checks if a file starts with the expected license header. +// The copyright year may be any 4-digit year; the rest must match exactly. func hasLicenseHeader(path string) (bool, error) { data, err := os.ReadFile(path) if err != nil { @@ -171,11 +191,19 @@ func hasLicenseHeader(path string) (bool, error) { } } - return strings.HasPrefix(content, header), nil + nl := strings.Index(content, "\n") + if nl < 0 { + return false, nil + } + if !copyrightLineRe.MatchString(content[:nl]) { + return false, nil + } + return strings.HasPrefix(content[nl+1:], licenseBody), nil } -// addLicenseHeader prepends the license header to a file. -// If the file starts with a //go:build directive, the header is placed after it. +// addLicenseHeader prepends the license header (stamped with the current year) +// to a file. If the file starts with a //go:build directive, the header is +// placed after it. func addLicenseHeader(path string) error { data, err := os.ReadFile(path) if err != nil { @@ -183,18 +211,20 @@ func addLicenseHeader(path string) error { } content := string(data) + hdr := header(time.Now().Year()) + var result string if strings.HasPrefix(content, "//go:build ") { idx := strings.Index(content, "\n") if idx >= 0 { buildLine := content[:idx+1] rest := content[idx+1:] - result = buildLine + "\n" + header + "\n\n" + strings.TrimLeft(rest, "\n") + result = buildLine + "\n" + hdr + "\n\n" + strings.TrimLeft(rest, "\n") } else { - result = content + "\n\n" + header + "\n" + result = content + "\n\n" + hdr + "\n" } } else { - result = header + "\n\n" + content + result = hdr + "\n\n" + content } return os.WriteFile(path, []byte(result), 0644) diff --git a/tool/linter/licenseheader/main_test.go b/tool/linter/licenseheader/main_test.go index 47f0bd52..56f829fb 100644 --- a/tool/linter/licenseheader/main_test.go +++ b/tool/linter/licenseheader/main_test.go @@ -19,6 +19,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,6 +47,8 @@ func TestIsGeneratedFile(t *testing.T) { func TestHasLicenseHeader(t *testing.T) { dir := t.TempDir() + curr := header(time.Now().Year()) + tests := []struct { name string content string @@ -53,7 +56,12 @@ func TestHasLicenseHeader(t *testing.T) { }{ { name: "has header", - content: header + "\n\npackage foo\n", + content: curr + "\n\npackage foo\n", + want: true, + }, + { + name: "older year still valid", + content: header(2025) + "\n\npackage foo\n", want: true, }, { @@ -63,7 +71,7 @@ func TestHasLicenseHeader(t *testing.T) { }, { name: "go:build then header", - content: "//go:build linux\n\n" + header + "\n\npackage foo\n", + content: "//go:build linux\n\n" + curr + "\n\npackage foo\n", want: true, }, { @@ -71,6 +79,16 @@ func TestHasLicenseHeader(t *testing.T) { content: "//go:build linux\n\npackage foo\n", want: false, }, + { + name: "wrong company fails", + content: "// Copyright (c) 2025 Someone Else, Inc.\n" + licenseBody + "\n\npackage foo\n", + want: false, + }, + { + name: "non-numeric year fails", + content: "// Copyright (c) YYYY Uber Technologies, Inc.\n" + licenseBody + "\n\npackage foo\n", + want: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -96,7 +114,7 @@ func TestAddLicenseHeader(t *testing.T) { require.NoError(t, err) content := string(data) - assert.True(t, strings.HasPrefix(content, header)) + assert.True(t, strings.HasPrefix(content, header(time.Now().Year()))) assert.Contains(t, content, "package foo") }) @@ -112,7 +130,7 @@ func TestAddLicenseHeader(t *testing.T) { content := string(data) assert.True(t, strings.HasPrefix(content, "//go:build linux\n")) - assert.Contains(t, content, header) + assert.Contains(t, content, header(time.Now().Year())) assert.Contains(t, content, "package foo") // Verify order: build directive, then header, then package @@ -158,7 +176,7 @@ func TestAddLicenseHeader(t *testing.T) { require.NoError(t, err) content := string(data) - assert.True(t, strings.HasPrefix(content, header)) + assert.True(t, strings.HasPrefix(content, header(time.Now().Year()))) assert.Contains(t, content, "syntax = \"proto3\"") }) }