From 7891123ecc6b410d70084ba9001c241e1af58bb0 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:22:15 -0500 Subject: [PATCH] Detect a Go module kept in src/ or one level down Language detection only looked for go.mod at the repository root, so probaci's own self-test, and any repository using the src/ module layout, reported languages=[] while every stage still passed. Detection now takes a root go.mod first, then src/go.mod, then the first other immediate subdirectory with one, skipping vendor/, testdata/, node_modules/ and "." or "_" prefixed directories. The language records its module directory: install and test commands become go -C , and lint and audit containers start in /workspace/, so golangci-lint and govulncheck find packages instead of passing on an empty root. The CI self-test now asserts the detect stage reports languages=[go]. --- .github/workflows/ci.yml | 7 ++ CHANGELOG.md | 8 +++ src/internal/detect/detect.go | 69 +++++++++++++++++-- src/internal/detect/detect_test.go | 105 +++++++++++++++++++++++++++++ src/internal/docker/broker.go | 10 ++- src/internal/docker/broker_test.go | 10 +++ src/internal/stage/stages.go | 9 ++- 7 files changed, 211 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 071a16c..892afd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,13 @@ jobs: # go -C src starts probaci inside src/; it walks up to the git root and # proves the whole repository, as it did from the root. - run: go -C src run ./cmd/probaci run --ci --only detect,workflow-lint,versions,secrets + # The module lives in src/, not at the root. Detection once missed it and + # reported languages=[] while every stage still passed, so assert it. + - name: Self-test detects Go in src/ + run: | + out=$(go -C src run ./cmd/probaci run --ci --only detect 2>&1) + echo "$out" + echo "$out" | grep -q 'languages=\[go\]' || { echo "::error::probaci did not detect its own Go module in src/"; exit 1; } # Cross-compile every target into build// on every pull request (and on # demand) and attach the binaries to the run, so a reviewer can try the exact diff --git a/CHANGELOG.md b/CHANGELOG.md index 604755c..f13e029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and ghcr.io login steps and its `packages: write` permission, and Dependabot's docker ecosystem are gone. No image was ever published, so nothing on ghcr.io is affected. +### Fixed + +- Language detection finds a Go module kept in `src/`, or in another directory one level down, + instead of only a root `go.mod`. Install and test commands become `go -C …`, and the lint + and audit containers start in that directory. `vendor/`, `testdata/`, `node_modules/` and + directories starting with `.` or `_` are never taken for the module. probaci's own self-test + reported `languages=[]` before this, and CI now asserts it detects Go. + ## [0.1.0] - 2026-06-18 ### Added diff --git a/src/internal/detect/detect.go b/src/internal/detect/detect.go index 00d6b43..16dd9dd 100644 --- a/src/internal/detect/detect.go +++ b/src/internal/detect/detect.go @@ -6,6 +6,8 @@ package detect import ( "os" "path/filepath" + "sort" + "strings" ) // Language describes a detected ecosystem and its default commands/tools. @@ -15,6 +17,9 @@ type Language struct { TestCmd string LintTool string // tool-registry key AuditTool string // tool-registry key + // Dir is the module's directory relative to the repository root ("" for + // the root itself). Commands and tools for this language run there. + Dir string } // Project is the result of detection. @@ -40,11 +45,8 @@ func DetectProject(dir string) Project { if exists(dir, "pyproject.toml") || exists(dir, "requirements.txt") { p.Languages = append(p.Languages, detectPython(dir)) } - if exists(dir, "go.mod") { - p.Languages = append(p.Languages, Language{ - Name: "go", InstallCmd: "go mod download", TestCmd: "go test ./...", - LintTool: "golangci-lint", AuditTool: "govulncheck", - }) + if modDir, ok := goModuleDir(dir); ok { + p.Languages = append(p.Languages, goLanguage(modDir)) } if exists(dir, "Cargo.toml") { p.Languages = append(p.Languages, Language{ @@ -64,6 +66,63 @@ func DetectProject(dir string) Project { return p } +// skipModuleDirs are never the project's own module: vendored copies, test +// fixtures and JavaScript dependencies. Directories starting with "." or "_" +// are skipped too, as the go tool itself ignores them. +var skipModuleDirs = map[string]bool{ + "vendor": true, + "testdata": true, + "node_modules": true, +} + +// goModuleDir finds the Go module: go.mod at the root, else in src/ (the +// layout every Simtabi Go repository uses), else in the first other immediate +// subdirectory, in name order. It never looks deeper than one level, so a +// fixture module elsewhere in the tree is not mistaken for the project. +func goModuleDir(dir string) (string, bool) { + if exists(dir, "go.mod") { + return "", true + } + if exists(filepath.Join(dir, "src"), "go.mod") { + return "src", true + } + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + var names []string + for _, e := range entries { + name := e.Name() + if !e.IsDir() || skipModuleDirs[name] || + strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if exists(filepath.Join(dir, name), "go.mod") { + return name, true + } + } + return "", false +} + +// goLanguage returns the Go defaults for a module in modDir (relative to the +// repository root). A nested module gets `go -C ` commands, because a +// bare `go test ./...` at the root finds no packages and passes. +func goLanguage(modDir string) Language { + l := Language{ + Name: "go", InstallCmd: "go mod download", TestCmd: "go test ./...", + LintTool: "golangci-lint", AuditTool: "govulncheck", Dir: modDir, + } + if modDir != "" { + l.InstallCmd = "go -C " + modDir + " mod download" + l.TestCmd = "go -C " + modDir + " test ./..." + } + return l +} + func detectNode(dir string, p *Project) (Language, bool) { if !exists(dir, "package.json") { return Language{}, false diff --git a/src/internal/detect/detect_test.go b/src/internal/detect/detect_test.go index fcdf5c1..b786a8f 100644 --- a/src/internal/detect/detect_test.go +++ b/src/internal/detect/detect_test.go @@ -66,3 +66,108 @@ func TestDetectEmpty(t *testing.T) { t.Fatalf("empty dir should detect nothing, got %+v", p.Languages) } } + +// mkdirWrite creates dir/sub and writes name inside it. +func mkdirWrite(t *testing.T, dir, sub, name string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, sub), name) +} + +// TestDetectGoModuleInSrc covers the src/ layout every Simtabi Go repository +// uses: go.mod lives in src/, not at the root. The commands and the module +// directory must point at src/, or `go test ./...` at the root finds no +// packages and passes. +func TestDetectGoModuleInSrc(t *testing.T) { + dir := t.TempDir() + mkdirWrite(t, dir, "src", "go.mod") + p := DetectProject(dir) + if len(p.Languages) != 1 || p.Languages[0].Name != "go" { + t.Fatalf("expected go from src/go.mod, got %+v", p.Languages) + } + l := p.Languages[0] + if l.Dir != "src" { + t.Errorf("Dir = %q, want %q", l.Dir, "src") + } + if l.InstallCmd != "go -C src mod download" || l.TestCmd != "go -C src test ./..." { + t.Errorf("commands must run in src/: install=%q test=%q", l.InstallCmd, l.TestCmd) + } +} + +// TestDetectGoModuleOneLevelDown finds a module in any immediate subdirectory, +// not only src/. +func TestDetectGoModuleOneLevelDown(t *testing.T) { + dir := t.TempDir() + mkdirWrite(t, dir, "service", "go.mod") + p := DetectProject(dir) + if len(p.Languages) != 1 || p.Languages[0].Dir != "service" { + t.Fatalf("expected go in service/, got %+v", p.Languages) + } +} + +// TestDetectGoModulePrefersRootThenSrc: a root go.mod wins over any nested +// one, and src/ wins over other subdirectories. +func TestDetectGoModulePrefersRootThenSrc(t *testing.T) { + root := t.TempDir() + write(t, root, "go.mod") + mkdirWrite(t, root, "src", "go.mod") + if p := DetectProject(root); len(p.Languages) != 1 || p.Languages[0].Dir != "" || + p.Languages[0].TestCmd != "go test ./..." { + t.Fatalf("root go.mod should win, got %+v", p.Languages) + } + + nested := t.TempDir() + mkdirWrite(t, nested, "aaa", "go.mod") + mkdirWrite(t, nested, "src", "go.mod") + if p := DetectProject(nested); len(p.Languages) != 1 || p.Languages[0].Dir != "src" { + t.Fatalf("src/ should win over other subdirectories, got %+v", p.Languages) + } +} + +// TestDetectGoModuleIgnoresNonModuleDirs: a go.mod under vendor/, testdata/, +// node_modules/ or a hidden or underscore directory is not the project's +// module (Go itself ignores testdata/, and "." and "_" prefixed directories). +func TestDetectGoModuleIgnoresNonModuleDirs(t *testing.T) { + dir := t.TempDir() + skipped := []string{"vendor", "testdata", "node_modules", ".cache", "_parked"} + for _, sub := range skipped { + mkdirWrite(t, dir, sub, "go.mod") + } + // Guard against an under-asserting setup: every fixture must exist. + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != len(skipped) { + t.Fatalf("fixture has %d dirs, want %d (err %v)", len(entries), len(skipped), err) + } + if p := DetectProject(dir); len(p.Languages) != 0 { + t.Fatalf("go.mod in excluded dirs must not count, got %+v", p.Languages) + } +} + +// TestDetectGoModuleNotTwoLevelsDown keeps the search to one level, so a +// fixture module deep in the tree is never mistaken for the project. +func TestDetectGoModuleNotTwoLevelsDown(t *testing.T) { + dir := t.TempDir() + mkdirWrite(t, dir, filepath.Join("tools", "gen"), "go.mod") + if p := DetectProject(dir); len(p.Languages) != 0 { + t.Fatalf("two levels down must not count, got %+v", p.Languages) + } +} + +// TestDetectSelf: probaci's own repository keeps its module in src/ and must +// detect as Go. This test runs in src/internal/detect, three levels below +// the repository root. +func TestDetectSelf(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + if !exists(filepath.Join(root, "src"), "go.mod") { + t.Fatalf("expected probaci's module at %s/src/go.mod", root) + } + p := DetectProject(root) + if len(p.Languages) == 0 || p.Languages[0].Name != "go" || p.Languages[0].Dir != "src" { + t.Fatalf("probaci itself should detect as go in src/, got %+v", p.Languages) + } +} diff --git a/src/internal/docker/broker.go b/src/internal/docker/broker.go index 2b48f5a..e6f0db7 100644 --- a/src/internal/docker/broker.go +++ b/src/internal/docker/broker.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "os/exec" + "path" "runtime" "strconv" "strings" @@ -47,6 +48,9 @@ type RunSpec struct { Args []string // RepoMount is the host path bind-mounted at /workspace. RepoMount string + // WorkDir is a directory relative to the mount to start in ("" for the + // mount root), e.g. "src" for a Go module kept in src/. + WorkDir string // Writable mounts the repo read-write; default is read-only. Writable bool // Network grants network access ("" means none — fully offline). @@ -214,7 +218,11 @@ func (b *Broker) buildArgs(spec RunSpec) []string { if !spec.Writable { mount += ":ro" } - args = append(args, "-v", mount, "-w", workspace) + workdir := workspace + if spec.WorkDir != "" { + workdir = path.Join(workspace, spec.WorkDir) + } + args = append(args, "-v", mount, "-w", workdir) } if spec.EnvFile != "" { diff --git a/src/internal/docker/broker_test.go b/src/internal/docker/broker_test.go index 1abe6b7..460832e 100644 --- a/src/internal/docker/broker_test.go +++ b/src/internal/docker/broker_test.go @@ -41,3 +41,13 @@ func TestBuildArgsWritable(t *testing.T) { t.Errorf("explicit network not honored; got %s", joined) } } + +// TestBuildArgsWorkDir: a tool for a module one level down starts in that +// directory inside the mount, so golangci-lint and govulncheck find packages. +func TestBuildArgsWorkDir(t *testing.T) { + b := &Broker{bin: "docker", cfg: config.Docker{}} + joined := strings.Join(b.buildArgs(RunSpec{Image: "golang", Args: []string{"true"}, RepoMount: "/repo", WorkDir: "src"}), " ") + if !strings.Contains(joined, "-w /workspace/src ") { + t.Errorf("expected -w /workspace/src; got %s", joined) + } +} diff --git a/src/internal/stage/stages.go b/src/internal/stage/stages.go index f02487c..d06c1c5 100644 --- a/src/internal/stage/stages.go +++ b/src/internal/stage/stages.go @@ -68,6 +68,12 @@ func (c *stageCtx) deps() platform.Deps { // brokerTool runs a registry tool over the repo and maps exit status to result. func (c *stageCtx) brokerTool(toolName string, extra ...string) result.Result { + return c.brokerToolIn("", toolName, extra...) +} + +// brokerToolIn runs a registry tool starting in workDir, a directory relative +// to the repository root ("" for the root), e.g. a Go module kept in src/. +func (c *stageCtx) brokerToolIn(workDir, toolName string, extra ...string) result.Result { if c.broker == nil { return skip("no container runtime; " + toolName + " skipped") } @@ -88,6 +94,7 @@ func (c *stageCtx) brokerTool(toolName string, extra ...string) result.Result { Entrypoint: t.Entrypoint, Args: args, RepoMount: c.repo, + WorkDir: filepath.ToSlash(workDir), Network: network, }) if c.emit != nil && out != "" { @@ -190,7 +197,7 @@ func (c *stageCtx) perLanguage(pick func(detect.Language) string) result.Result if name == "" { continue } - r := c.brokerTool(name) + r := c.brokerToolIn(l.Dir, name) ran++ outputs = append(outputs, fmt.Sprintf("[%s/%s] %s", l.Name, name, r.Summary)) worst = worsen(worst, r.Status)