Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<os>/ on every pull request (and on
# demand) and attach the binaries to the run, so a reviewer can try the exact
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> …`, 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
Expand Down
69 changes: 64 additions & 5 deletions src/internal/detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package detect
import (
"os"
"path/filepath"
"sort"
"strings"
)

// Language describes a detected ecosystem and its default commands/tools.
Expand All @@ -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.
Expand All @@ -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{
Expand All @@ -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 <dir>` 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
Expand Down
105 changes: 105 additions & 0 deletions src/internal/detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 9 additions & 1 deletion src/internal/docker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"io"
"os/exec"
"path"
"runtime"
"strconv"
"strings"
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 != "" {
Expand Down
10 changes: 10 additions & 0 deletions src/internal/docker/broker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
9 changes: 8 additions & 1 deletion src/internal/stage/stages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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)
Expand Down
Loading