Skip to content

Commit 84b859b

Browse files
authored
Merge pull request #2568 from dgageot/board/custom-go-linter-ideas-for-code-robustne-bdb66fc1
lint: add config-versioning robustness cops + fix v6/v7 package names
2 parents b962816 + 0e87843 commit 84b859b

22 files changed

Lines changed: 371 additions & 99 deletions

lint/config_package_name.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"go/ast"
6+
"go/token"
7+
"strings"
8+
9+
"github.com/dgageot/rubocop-go/cop"
10+
)
11+
12+
// ConfigPackageName enforces that files under pkg/config/<dir>/ declare a
13+
// package name that matches their directory:
14+
//
15+
// - pkg/config/vN/ → package vN
16+
// - pkg/config/latest/ → package latest
17+
// - pkg/config/types/ → package types
18+
//
19+
// This catches a class of copy-paste bugs that occur when a "latest" version
20+
// is frozen into a numbered vN directory: the package clause is easy to
21+
// forget, and the broken state remains compilable as long as importers use
22+
// an explicit alias.
23+
type ConfigPackageName struct{}
24+
25+
func (*ConfigPackageName) Name() string { return "Lint/ConfigPackageName" }
26+
func (*ConfigPackageName) Description() string {
27+
return "Files under pkg/config/<dir>/ must declare package <dir>"
28+
}
29+
func (*ConfigPackageName) Severity() cop.Severity { return cop.Error }
30+
31+
func (c *ConfigPackageName) Check(fset *token.FileSet, file *ast.File) []cop.Offense {
32+
filename := fset.Position(file.Package).Filename
33+
dir := configDir(filename)
34+
if dir == "" {
35+
return nil
36+
}
37+
38+
got := file.Name.Name
39+
switch got {
40+
case dir:
41+
return nil
42+
case dir + "_test":
43+
// Black-box test packages are a legitimate Go convention.
44+
if strings.HasSuffix(filename, "_test.go") {
45+
return nil
46+
}
47+
}
48+
49+
return []cop.Offense{offense(c, fset, file.Name,
50+
fmt.Sprintf("file in pkg/config/%s/ must declare package %s, got %s", dir, dir, got))}
51+
}

lint/config_version_constant.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"go/ast"
6+
"go/token"
7+
"strconv"
8+
9+
"github.com/dgageot/rubocop-go/cop"
10+
)
11+
12+
// ConfigVersionConstant enforces that a file under pkg/config/vN/ declaring a
13+
// `const Version = "<value>"` uses "<N>" as its value.
14+
//
15+
// This guards against the common mistake of bumping the directory name
16+
// without bumping the constant (or vice versa) when freezing the
17+
// work-in-progress config and creating a new "latest". A mismatch would
18+
// silently break the parser dispatch in pkg/config/versions.go, which
19+
// registers parsers keyed by `Version`.
20+
//
21+
// Files under pkg/config/latest/ are intentionally exempt: their `Version`
22+
// is the next, work-in-progress value (one greater than the highest vN).
23+
type ConfigVersionConstant struct{}
24+
25+
func (*ConfigVersionConstant) Name() string { return "Lint/ConfigVersionConstant" }
26+
func (*ConfigVersionConstant) Description() string {
27+
return "Version constant in pkg/config/vN/ must equal \"N\""
28+
}
29+
func (*ConfigVersionConstant) Severity() cop.Severity { return cop.Error }
30+
31+
func (c *ConfigVersionConstant) Check(fset *token.FileSet, file *ast.File) []cop.Offense {
32+
dirVersion, ok := versionFromDir(configDir(fset.Position(file.Package).Filename))
33+
if !ok {
34+
return nil
35+
}
36+
expected := strconv.Itoa(dirVersion)
37+
38+
var offenses []cop.Offense
39+
for _, lit := range versionStringLiterals(file) {
40+
got, err := strconv.Unquote(lit.Value)
41+
if err != nil || got == expected {
42+
continue
43+
}
44+
offenses = append(offenses, offense(c, fset, lit,
45+
fmt.Sprintf("Version in pkg/config/v%s/ must be %q, got %q", expected, expected, got)))
46+
}
47+
return offenses
48+
}
49+
50+
// versionStringLiterals returns the value literal of every top-level
51+
// `const Version = "<string>"` declaration in file.
52+
func versionStringLiterals(file *ast.File) []*ast.BasicLit {
53+
var lits []*ast.BasicLit
54+
for _, decl := range file.Decls {
55+
gen, ok := decl.(*ast.GenDecl)
56+
if !ok || gen.Tok != token.CONST {
57+
continue
58+
}
59+
for _, spec := range gen.Specs {
60+
vs, ok := spec.(*ast.ValueSpec)
61+
if !ok {
62+
continue
63+
}
64+
for i, name := range vs.Names {
65+
if name.Name != "Version" || i >= len(vs.Values) {
66+
continue
67+
}
68+
lit, ok := vs.Values[i].(*ast.BasicLit)
69+
if !ok || lit.Kind != token.STRING {
70+
continue
71+
}
72+
lits = append(lits, lit)
73+
}
74+
}
75+
}
76+
return lits
77+
}

lint/config_version_import.go

Lines changed: 42 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@ import (
44
"fmt"
55
"go/ast"
66
"go/token"
7-
"path/filepath"
8-
"regexp"
9-
"strconv"
107
"strings"
118

129
"github.com/dgageot/rubocop-go/cop"
1310
)
1411

15-
// ConfigVersionImport enforces that config version packages (pkg/config/vN)
16-
// only import their immediate predecessor (pkg/config/v{N-1}) and the shared
17-
// types package (pkg/config/types). This preserves the strict migration chain:
12+
// ConfigVersionImport enforces that config version packages (pkg/config/vN
13+
// and pkg/config/latest) only import their immediate predecessor and the
14+
// shared types package, preserving the strict migration chain:
1815
// v0 → v1 → v2 → … → latest.
1916
type ConfigVersionImport struct{}
2017

@@ -24,100 +21,69 @@ func (*ConfigVersionImport) Description() string {
2421
}
2522
func (*ConfigVersionImport) Severity() cop.Severity { return cop.Error }
2623

27-
// configVersionRe matches "pkg/config/vN" at the end of an import path.
28-
var configVersionRe = regexp.MustCompile(`pkg/config/v(\d+)$`)
29-
30-
// Check inspects import declarations in config version packages.
3124
func (c *ConfigVersionImport) Check(fset *token.FileSet, file *ast.File) []cop.Offense {
3225
if len(file.Imports) == 0 {
3326
return nil
3427
}
3528

36-
// Determine which config version package this file belongs to.
37-
filename := fset.Position(file.Package).Filename
38-
dirVersion, isVersioned := extractDirVersion(filename)
39-
dirIsLatest := isLatestDir(filename)
29+
dir := configDir(fset.Position(file.Package).Filename)
30+
if dir == "" {
31+
return nil
32+
}
33+
// Black-box test files (package <dir>_test) are external to the package
34+
// and may import what they please.
35+
if strings.HasSuffix(file.Name.Name, "_test") {
36+
return nil
37+
}
4038

41-
if !isVersioned && !dirIsLatest {
39+
dirVersion, isVersioned := versionFromDir(dir)
40+
isLatest := dir == "latest"
41+
if !isVersioned && !isLatest {
4242
return nil
4343
}
4444

4545
var offenses []cop.Offense
46-
4746
for _, imp := range file.Imports {
48-
importPath := strings.Trim(imp.Path.Value, `"`)
49-
50-
if !strings.Contains(importPath, "pkg/config/") {
51-
continue
52-
}
47+
path := importPath(imp)
5348

54-
if strings.HasSuffix(importPath, "pkg/config/types") {
49+
if !strings.Contains(path, "pkg/config/") || strings.HasSuffix(path, "pkg/config/types") {
5550
continue
5651
}
57-
58-
if isVersioned {
59-
offenses = append(offenses, c.checkVersionedImport(fset, imp, importPath, dirVersion)...)
60-
} else if dirIsLatest {
61-
offenses = append(offenses, c.checkLatestImport(fset, imp, importPath)...)
52+
if msg := importViolation(path, dirVersion, isLatest); msg != "" {
53+
offenses = append(offenses, offense(c, fset, imp.Path, msg))
6254
}
6355
}
64-
6556
return offenses
6657
}
6758

68-
func (c *ConfigVersionImport) checkVersionedImport(fset *token.FileSet, imp *ast.ImportSpec, importPath string, dirVersion int) []cop.Offense {
69-
if strings.HasSuffix(importPath, "pkg/config/latest") {
70-
return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(),
71-
fmt.Sprintf("config v%d must not import pkg/config/latest", dirVersion))}
59+
// importViolation returns a non-empty error message if the given import path
60+
// is forbidden inside a config-version package, or "" if the import is fine.
61+
// dirVersion is the importing package's N (only meaningful when !isLatest).
62+
func importViolation(path string, dirVersion int, isLatest bool) string {
63+
if isLatest {
64+
// pkg/config/latest may only import other config-version packages.
65+
// (The "must be the immediate predecessor" rule lives in the
66+
// LatestImportsPredecessor cop.)
67+
if _, ok := versionFromImport(path); ok {
68+
return ""
69+
}
70+
return "pkg/config/latest must only import config version or types packages, not " + path
7271
}
7372

74-
m := configVersionRe.FindStringSubmatch(importPath)
75-
if m == nil {
76-
return nil
73+
// Versioned package (vN).
74+
if strings.HasSuffix(path, "pkg/config/latest") {
75+
return fmt.Sprintf("config v%d must not import pkg/config/latest", dirVersion)
76+
}
77+
imported, ok := versionFromImport(path)
78+
if !ok {
79+
return ""
7780
}
78-
79-
importedVersion, _ := strconv.Atoi(m[1])
8081
expected := dirVersion - 1
81-
8282
if expected < 0 {
83-
return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(),
84-
"config v0 must not import other config version packages")}
83+
return "config v0 must not import other config version packages"
8584
}
86-
87-
if importedVersion != expected {
88-
return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(),
89-
fmt.Sprintf("config v%d must import v%d (its predecessor), not v%d", dirVersion, expected, importedVersion))}
90-
}
91-
92-
return nil
93-
}
94-
95-
func (c *ConfigVersionImport) checkLatestImport(fset *token.FileSet, imp *ast.ImportSpec, importPath string) []cop.Offense {
96-
if configVersionRe.MatchString(importPath) {
97-
return nil
85+
if imported != expected {
86+
return fmt.Sprintf("config v%d must import v%d (its predecessor), not v%d", dirVersion, expected, imported)
9887
}
99-
100-
return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(),
101-
"pkg/config/latest should only import config version or types packages, not "+importPath)}
102-
}
103-
104-
func extractDirVersion(filename string) (int, bool) {
105-
normalized := filepath.ToSlash(filename)
106-
107-
re := regexp.MustCompile(`/pkg/config/v(\d+)/`)
108-
m := re.FindStringSubmatch(normalized)
109-
if m == nil {
110-
return 0, false
111-
}
112-
113-
v, err := strconv.Atoi(m[1])
114-
if err != nil {
115-
return 0, false
116-
}
117-
return v, true
118-
}
119-
120-
func isLatestDir(filename string) bool {
121-
normalized := filepath.ToSlash(filename)
122-
return strings.Contains(normalized, "/pkg/config/latest/")
88+
return ""
12389
}

0 commit comments

Comments
 (0)