diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb7f00fab..81ee09b3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: "1.26.0" + go-version: "1.26.1" cache: true - name: Lint @@ -48,7 +48,7 @@ jobs: - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: "1.26.0" + go-version: "1.26.1" cache: true - name: Install Task @@ -76,7 +76,7 @@ jobs: - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: "1.26.0" + go-version: "1.26.1" cache: true - name: Install go-licences diff --git a/.gitignore b/.gitignore index f30628c37..12ba99c77 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ evals .vscode .idea/ *.debug +/lint/lint # agents agent.yaml diff --git a/Dockerfile b/Dockerfile index 0722e5c98..b786b11a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -ARG GO_VERSION="1.26.0" +ARG GO_VERSION="1.26.1" ARG ALPINE_VERSION="3.22" ARG XX_VERSION="1.9.0" diff --git a/Taskfile.yml b/Taskfile.yml index a3ca6fe7d..f18a45850 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -46,6 +46,7 @@ tasks: desc: Run golangci-lint cmds: - golangci-lint run + - go run ./lint . - go mod tidy --diff >/dev/null || (echo "go.mod/go.sum files are not tidy" && exit 1) sources: - "{{.GO_SOURCES}}" diff --git a/go.mod b/go.mod index b43517757..f2915a319 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/docker/docker-agent -go 1.26.0 +go 1.26.1 require ( charm.land/bubbles/v2 v2.0.0 @@ -128,6 +128,7 @@ require ( github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgageot/rubocop-go v0.0.0-20260323134452-aecdd6345645 github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect diff --git a/go.sum b/go.sum index 15ffd0ae6..91e5162f0 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgageot/rubocop-go v0.0.0-20260323134452-aecdd6345645 h1:7UgEWAo69Dgbtii1j1FLWE88+Rem9Qly4LLrrQhAN0s= +github.com/dgageot/rubocop-go v0.0.0-20260323134452-aecdd6345645/go.mod h1:r8YOJV5+/30NZ8HW/2NbWUObBGDXGvfHrjgury5YlFI= github.com/dgageot/ultraviolet v0.0.0-20260313154905-9451997d56b6 h1:88fWkkjwzuI4tRTqadbJIbA9O+gO67oyu+2OpHHuuT8= github.com/dgageot/ultraviolet v0.0.0-20260313154905-9451997d56b6/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= diff --git a/lint/config_version_import.go b/lint/config_version_import.go new file mode 100644 index 000000000..40b6d2b3f --- /dev/null +++ b/lint/config_version_import.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "go/ast" + "go/token" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/dgageot/rubocop-go/cop" +) + +// ConfigVersionImport enforces that config version packages (pkg/config/vN) +// only import their immediate predecessor (pkg/config/v{N-1}) and the shared +// types package (pkg/config/types). This preserves the strict migration chain: +// v0 → v1 → v2 → … → latest. +type ConfigVersionImport struct{} + +func (*ConfigVersionImport) Name() string { return "Lint/ConfigVersionImport" } +func (*ConfigVersionImport) Description() string { + return "Config version packages must only import their immediate predecessor" +} +func (*ConfigVersionImport) Severity() cop.Severity { return cop.Error } + +// configVersionRe matches "pkg/config/vN" at the end of an import path. +var configVersionRe = regexp.MustCompile(`pkg/config/v(\d+)$`) + +// Check inspects import declarations in config version packages. +func (c *ConfigVersionImport) Check(fset *token.FileSet, file *ast.File) []cop.Offense { + if len(file.Imports) == 0 { + return nil + } + + // Determine which config version package this file belongs to. + filename := fset.Position(file.Package).Filename + dirVersion, isVersioned := extractDirVersion(filename) + dirIsLatest := isLatestDir(filename) + + if !isVersioned && !dirIsLatest { + return nil + } + + var offenses []cop.Offense + + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + + if !strings.Contains(importPath, "pkg/config/") { + continue + } + + if strings.HasSuffix(importPath, "pkg/config/types") { + continue + } + + if isVersioned { + offenses = append(offenses, c.checkVersionedImport(fset, imp, importPath, dirVersion)...) + } else if dirIsLatest { + offenses = append(offenses, c.checkLatestImport(fset, imp, importPath)...) + } + } + + return offenses +} + +func (c *ConfigVersionImport) checkVersionedImport(fset *token.FileSet, imp *ast.ImportSpec, importPath string, dirVersion int) []cop.Offense { + if strings.HasSuffix(importPath, "pkg/config/latest") { + return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(), + fmt.Sprintf("config v%d must not import pkg/config/latest", dirVersion))} + } + + m := configVersionRe.FindStringSubmatch(importPath) + if m == nil { + return nil + } + + importedVersion, _ := strconv.Atoi(m[1]) + expected := dirVersion - 1 + + if expected < 0 { + return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(), + "config v0 must not import other config version packages")} + } + + if importedVersion != expected { + return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(), + fmt.Sprintf("config v%d must import v%d (its predecessor), not v%d", dirVersion, expected, importedVersion))} + } + + return nil +} + +func (c *ConfigVersionImport) checkLatestImport(fset *token.FileSet, imp *ast.ImportSpec, importPath string) []cop.Offense { + if configVersionRe.MatchString(importPath) { + return nil + } + + return []cop.Offense{cop.NewOffense(c, fset, imp.Path.Pos(), imp.Path.End(), + "pkg/config/latest should only import config version or types packages, not "+importPath)} +} + +func extractDirVersion(filename string) (int, bool) { + normalized := filepath.ToSlash(filename) + + re := regexp.MustCompile(`/pkg/config/v(\d+)/`) + m := re.FindStringSubmatch(normalized) + if m == nil { + return 0, false + } + + v, err := strconv.Atoi(m[1]) + if err != nil { + return 0, false + } + return v, true +} + +func isLatestDir(filename string) bool { + normalized := filepath.ToSlash(filename) + return strings.Contains(normalized, "/pkg/config/latest/") +} diff --git a/lint/main.go b/lint/main.go new file mode 100644 index 000000000..ee83b8c0b --- /dev/null +++ b/lint/main.go @@ -0,0 +1,37 @@ +// Package main runs project-specific linting cops using rubocop-go. +// +// Usage: go run ./lint ./... +package main + +import ( + "fmt" + "os" + + "github.com/dgageot/rubocop-go/config" + "github.com/dgageot/rubocop-go/cop" + "github.com/dgageot/rubocop-go/runner" +) + +func main() { + cop.Register(&ConfigVersionImport{}) + cops := cop.All() + fmt.Printf("Inspecting Go files with %d cop(s)\n", len(cops)) + + cfg := config.DefaultConfig() + r := runner.New(cops, cfg, os.Stdout) + + paths := os.Args[1:] + if len(paths) == 0 { + paths = []string{"."} + } + + offenseCount, err := r.Run(paths) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if offenseCount > 0 { + os.Exit(1) + } +}