Skip to content

Commit 3336e23

Browse files
authored
Merge pull request #2225 from dgageot/board/custom-linter-for-codebase-constraints-a5566d20
Add custom linter to enforce config version import chain
2 parents b93924f + 6c810bf commit 3336e23

File tree

8 files changed

+170
-5
lines changed

8 files changed

+170
-5
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: Set up Go
2626
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
2727
with:
28-
go-version: "1.26.0"
28+
go-version: "1.26.1"
2929
cache: true
3030

3131
- name: Lint
@@ -48,7 +48,7 @@ jobs:
4848
- name: Set up Go
4949
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
5050
with:
51-
go-version: "1.26.0"
51+
go-version: "1.26.1"
5252
cache: true
5353

5454
- name: Install Task
@@ -76,7 +76,7 @@ jobs:
7676
- name: Set up Go
7777
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
7878
with:
79-
go-version: "1.26.0"
79+
go-version: "1.26.1"
8080
cache: true
8181

8282
- name: Install go-licences

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ evals
88
.vscode
99
.idea/
1010
*.debug
11+
/lint/lint
1112

1213
# agents
1314
agent.yaml

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# syntax=docker/dockerfile:1
22

3-
ARG GO_VERSION="1.26.0"
3+
ARG GO_VERSION="1.26.1"
44
ARG ALPINE_VERSION="3.22"
55
ARG XX_VERSION="1.9.0"
66

Taskfile.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ tasks:
4646
desc: Run golangci-lint
4747
cmds:
4848
- golangci-lint run
49+
- go run ./lint .
4950
- go mod tidy --diff >/dev/null || (echo "go.mod/go.sum files are not tidy" && exit 1)
5051
sources:
5152
- "{{.GO_SOURCES}}"

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/docker/docker-agent
22

3-
go 1.26.0
3+
go 1.26.1
44

55
require (
66
charm.land/bubbles/v2 v2.0.0
@@ -128,6 +128,7 @@ require (
128128
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
129129
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
130130
github.com/davecgh/go-spew v1.1.1 // indirect
131+
github.com/dgageot/rubocop-go v0.0.0-20260323134452-aecdd6345645
131132
github.com/distribution/reference v0.6.0 // indirect
132133
github.com/dlclark/regexp2 v1.11.5 // indirect
133134
github.com/docker/distribution v2.8.3+incompatible // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL
178178
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
179179
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
180180
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
181+
github.com/dgageot/rubocop-go v0.0.0-20260323134452-aecdd6345645 h1:7UgEWAo69Dgbtii1j1FLWE88+Rem9Qly4LLrrQhAN0s=
182+
github.com/dgageot/rubocop-go v0.0.0-20260323134452-aecdd6345645/go.mod h1:r8YOJV5+/30NZ8HW/2NbWUObBGDXGvfHrjgury5YlFI=
181183
github.com/dgageot/ultraviolet v0.0.0-20260313154905-9451997d56b6 h1:88fWkkjwzuI4tRTqadbJIbA9O+gO67oyu+2OpHHuuT8=
182184
github.com/dgageot/ultraviolet v0.0.0-20260313154905-9451997d56b6/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
183185
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=

lint/config_version_import.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"go/ast"
6+
"go/token"
7+
"path/filepath"
8+
"regexp"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/dgageot/rubocop-go/cop"
13+
)
14+
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:
18+
// v0 → v1 → v2 → … → latest.
19+
type ConfigVersionImport struct{}
20+
21+
func (*ConfigVersionImport) Name() string { return "Lint/ConfigVersionImport" }
22+
func (*ConfigVersionImport) Description() string {
23+
return "Config version packages must only import their immediate predecessor"
24+
}
25+
func (*ConfigVersionImport) Severity() cop.Severity { return cop.Error }
26+
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.
31+
func (c *ConfigVersionImport) Check(fset *token.FileSet, file *ast.File) []cop.Offense {
32+
if len(file.Imports) == 0 {
33+
return nil
34+
}
35+
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)
40+
41+
if !isVersioned && !dirIsLatest {
42+
return nil
43+
}
44+
45+
var offenses []cop.Offense
46+
47+
for _, imp := range file.Imports {
48+
importPath := strings.Trim(imp.Path.Value, `"`)
49+
50+
if !strings.Contains(importPath, "pkg/config/") {
51+
continue
52+
}
53+
54+
if strings.HasSuffix(importPath, "pkg/config/types") {
55+
continue
56+
}
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)...)
62+
}
63+
}
64+
65+
return offenses
66+
}
67+
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))}
72+
}
73+
74+
m := configVersionRe.FindStringSubmatch(importPath)
75+
if m == nil {
76+
return nil
77+
}
78+
79+
importedVersion, _ := strconv.Atoi(m[1])
80+
expected := dirVersion - 1
81+
82+
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")}
85+
}
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
98+
}
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/")
123+
}

lint/main.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Package main runs project-specific linting cops using rubocop-go.
2+
//
3+
// Usage: go run ./lint ./...
4+
package main
5+
6+
import (
7+
"fmt"
8+
"os"
9+
10+
"github.com/dgageot/rubocop-go/config"
11+
"github.com/dgageot/rubocop-go/cop"
12+
"github.com/dgageot/rubocop-go/runner"
13+
)
14+
15+
func main() {
16+
cop.Register(&ConfigVersionImport{})
17+
cops := cop.All()
18+
fmt.Printf("Inspecting Go files with %d cop(s)\n", len(cops))
19+
20+
cfg := config.DefaultConfig()
21+
r := runner.New(cops, cfg, os.Stdout)
22+
23+
paths := os.Args[1:]
24+
if len(paths) == 0 {
25+
paths = []string{"."}
26+
}
27+
28+
offenseCount, err := r.Run(paths)
29+
if err != nil {
30+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
31+
os.Exit(1)
32+
}
33+
34+
if offenseCount > 0 {
35+
os.Exit(1)
36+
}
37+
}

0 commit comments

Comments
 (0)