From 9ee2a258df14e31f4666617dec933afa9aa5b369 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 1 Oct 2026 08:02:32 +0100 Subject: [PATCH] Use path roles for toolchain detection --- README.md | 2 + cmd/brief/main_test.go | 33 +++++++++ detect/detect.go | 50 +++++++++----- detect/roles.go | 62 +++++++++++++++++ detect/roles_test.go | 147 +++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 7 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 detect/roles.go create mode 100644 detect/roles_test.go diff --git a/README.md b/README.md index 178b7d3..2c52602 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ brief pypi:requests Local scans inspect up to eight directory levels and 10,000 filesystem entries by default. Use `--scan-depth N` or `--scan-limit N` to change those bounds. External line counters have a two-second limit, configurable with `--line-count-timeout D`, and reports mark truncated scans. Set any of these three values to `0` to remove that bound. Use `--skip dir1,dir2` to add directory exclusions, or `--tracked` to consider only files tracked by Git. `--include-submodules` scans initialized Git submodules recursively, including one below an otherwise skipped directory such as `vendor/`; neighboring vendored files and missing submodule worktrees stay excluded. When Rust source exists without a root `Cargo.toml`, brief uses the shallowest `Cargo.toml` within the scan depth as an additional manifest root. Cargo workspace member manifests are also checked for tool configuration. +Path roles filter the files used as detection evidence. Fixtures, vendored files, caches and build output do not contribute tools or dependencies. Language file matching also excludes generated files, tests, examples, benchmarks, fuzz targets, documentation and helper scripts. Ranking, inferred style and inferred source directories use the remaining source-role files and unclassified paths such as `main.go`. Project manifests can establish a language without counting as source files. Test files remain available for test-tool detection, and generated lockfiles remain dependency evidence. These filters do not change external line-count totals. + Remote sources are shallow-cloned by default. Use `--depth 0` for a full clone, `--keep` to preserve the clone, or `--dir ./somewhere` to clone into a specific directory. Use `--cache ./cache` to keep one shallow checkout per HTTPS URL and reuse it across runs. Cache mode cannot be combined with `--depth 0` or `--dir`. JSON when piped, human-readable on a TTY. Force either with `--json` or `--human`. Use `--category test` to filter to a single category. diff --git a/cmd/brief/main_test.go b/cmd/brief/main_test.go index a59566a..f83ba9e 100644 --- a/cmd/brief/main_test.go +++ b/cmd/brief/main_test.go @@ -18,6 +18,39 @@ const submoduleHelperRootEnv = "BRIEF_SUBMODULE_HELPER_ROOT" const submoduleDiffHelperEnv = "BRIEF_SUBMODULE_DIFF_HELPER" const yamlResourceDiffHelperEnv = "BRIEF_YAML_RESOURCE_DIFF_HELPER" +func TestScanUsesPathRoles(t *testing.T) { + const helperEnv = "BRIEF_ROLES_HELPER_ROOT" + if root := os.Getenv(helperEnv); root != "" { + cmdScan([]string{"-json", root}) + return + } + root := t.TempDir() + writeScanFixture(t, root, "main.go", "package main\nfunc main() {\n\tprintln(1)\n}\n") + for _, name := range []string{"evals/fixtures/a/app.py", "evals/fixtures/b/app.py", "skills/semgrep/scripts/scan.py"} { + writeScanFixture(t, root, name, "def run():\n print('fixture')\n") + } + writeScanFixture(t, root, "fixtures/deploy.yaml", "apiVersion: argoproj.io/v1alpha1\nkind: Application\n") + cmd := exec.Command(os.Args[0], "-test.run=^TestScanUsesPathRoles$") + cmd.Env = append(os.Environ(), helperEnv+"="+root, "PATH=") + out, err := cmd.Output() + if err != nil { + t.Fatalf("scan command failed: %v", err) + } + var report brief.Report + if err := json.Unmarshal(out, &report); err != nil { + t.Fatalf("parsing scan output: %v\n%s", err, out) + } + if len(report.Languages) != 1 || report.Languages[0].Name != "Go" { + t.Errorf("languages = %+v, want only Go", report.Languages) + } + if report.Style == nil || report.Style.Indentation != "tabs" { + t.Errorf("style = %+v, want tabs", report.Style) + } + if reportHasTool(&report, "infrastructure", "Argo CD") { + t.Error("fixture manifest triggered Argo CD") + } +} + func TestScanDefaultsBoundRecursiveDetection(t *testing.T) { if root := os.Getenv(scanHelperRootEnv); root != "" { cmdScan([]string{"-json", root}) diff --git a/detect/detect.go b/detect/detect.go index 8392688..537970c 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -25,6 +25,7 @@ import ( "github.com/git-pkgs/brief/kb" "github.com/git-pkgs/licensecheck" "github.com/git-pkgs/manifests" + "github.com/git-pkgs/roles" "github.com/git-pkgs/spdx" "go.yaml.in/yaml/v3" ) @@ -47,6 +48,7 @@ const ( categoryDocs = "docs" categoryFormat = "format" categoryLint = "lint" + categoryLanguage = "language" categoryTest = "test" categoryTypecheck = "typecheck" lineCounterSCC = "scc" @@ -77,6 +79,7 @@ type Engine struct { trackedDirs map[string]bool // directories that contain at least one tracked file trackedDeps map[string]bool // whether a deps directory contains git-tracked files fileExts map[string]int // cached file extension counts in the project + fileRoles map[string]roles.Set dirCache map[string][]string depsLoaded bool runtimeDeps map[string]bool // all runtime/unscoped dependency names @@ -112,19 +115,10 @@ func (e *Engine) sortLanguagesByFileCount(report *brief.Report) { e.loadFileExts() - // Score each language by summing file counts for its extensions scores := make(map[string]int) for _, lang := range report.Languages { - tool := e.KB.ByName[lang.Name] - if tool == nil { - continue - } - for _, pattern := range tool.Detect.Files { - // Extract extension from patterns like "*.py" or "**/*.py" - if idx := strings.LastIndex(pattern, "*."); idx >= 0 { - ext := pattern[idx+1:] // ".py" - scores[lang.Name] += e.fileExts[ext] - } + for _, ext := range e.languageExtensions(lang.Name) { + scores[lang.Name] += e.fileExts[ext] } } @@ -336,7 +330,7 @@ func (e *Engine) Run() (*brief.Report, error) { Tools: make(map[string][]brief.Detection), } - report.Languages = e.detectCategory("language") + report.Languages = e.detectCategory(categoryLanguage) e.sortLanguagesByFileCount(report) e.buildEcosystemSet(report) @@ -434,7 +428,7 @@ func (e *Engine) buildEcosystemSet(report *brief.Report) { e.detectedEcosystems = make(map[string]bool) for _, lang := range report.Languages { for _, tool := range e.KB.Tools { - if tool.Tool.Name == lang.Name && tool.Tool.Category == "language" { + if tool.Tool.Name == lang.Name && tool.Tool.Category == categoryLanguage { for _, eco := range tool.Detect.Ecosystems { e.detectedEcosystems[eco] = true } @@ -576,7 +570,13 @@ func (e *Engine) matchTool(tool *kb.ToolDef) brief.Confidence { best := brief.Confidence("") for _, pattern := range tool.Detect.Files { - if e.exists(pattern) { + matches := false + if tool.Tool.Category == categoryLanguage && kb.HasGlobPattern(pattern) && !strings.HasSuffix(pattern, "/") { + matches = e.languageFileExists(pattern) + } else { + matches = e.exists(pattern) + } + if matches { conf := brief.ConfidenceMedium if strings.HasSuffix(pattern, "/") { conf = brief.ConfidenceLow @@ -632,6 +632,9 @@ func (e *Engine) exists(pattern string) bool { } for _, root := range e.analysisRoots() { candidate := filepath.Join(root, filepath.FromSlash(dir)) + if !e.projectEvidence(filepath.ToSlash(candidate) + "/") { + continue + } info, err := os.Stat(filepath.Join(e.Root, candidate)) if err == nil && info.IsDir() && e.isTracked(candidate) { return true @@ -653,6 +656,9 @@ func (e *Engine) exists(pattern string) bool { } func (e *Engine) exactFileExists(file string) bool { + if !e.projectEvidence(file) { + return false + } info, err := os.Stat(filepath.Join(e.Root, filepath.FromSlash(file))) return err == nil && info.Mode().IsRegular() && e.isTracked(filepath.FromSlash(file)) } @@ -717,6 +723,7 @@ func (e *Engine) loadProjectFiles() { return } e.projectFilesLoaded = true + e.fileRoles = make(map[string]roles.Set) visited := 0 e.scanProjectDir(e.Root, "", &visited, false) e.scanEntries = visited @@ -779,8 +786,9 @@ func (e *Engine) scanProjectEntry( } if !info.IsDir() { if info.Mode().IsRegular() && e.isTracked(rel) { + e.fileRoles[rel] = e.pathRoles(rel) e.indexedFiles = append(e.indexedFiles, rel) - if !routeOnly { + if !routeOnly && e.projectEvidence(rel) { e.projectFiles = append(e.projectFiles, rel) } } @@ -815,7 +823,7 @@ func (e *Engine) scanProjectEntry( nextRouteOnly = false } e.indexedDirs = append(e.indexedDirs, rel) - if !nextRouteOnly { + if !nextRouteOnly && e.projectEvidence(filepath.ToSlash(rel)+"/") { e.projectDirs = append(e.projectDirs, rel) } return e.scanProjectDir(filePath, rel, visited, nextRouteOnly) @@ -844,6 +852,9 @@ func (e *Engine) loadFileExts() { e.loadProjectFiles() e.fileExts = make(map[string]int) for _, rel := range e.projectFiles { + if !e.sourceEvidence(rel) { + continue + } if ext := filepath.Ext(rel); ext != "" { e.fileExts[ext]++ } @@ -1126,6 +1137,9 @@ func (e *Engine) manifestPaths() []string { if p == "." || strings.HasPrefix(p, "../") || filepath.IsAbs(p) { return } + if !e.projectEvidence(p) { + return + } if seen[p] { return } @@ -1751,7 +1765,7 @@ func (e *Engine) inferStyle() *brief.StyleInfo { if sc.sampled >= limit { break } - if !exts[filepath.Ext(rel)] { + if !exts[filepath.Ext(rel)] || !e.sourceEvidence(rel) { continue } data, err := e.safeReadFile(rel) @@ -1868,7 +1882,7 @@ func (e *Engine) languageExtensions(name string) []string { func (e *Engine) projectDirHasExtension(dir string, exts []string) bool { for _, file := range e.projectFiles { - if filepath.Dir(file) != dir { + if filepath.Dir(file) != dir || !e.sourceEvidence(file) { continue } for _, want := range exts { diff --git a/detect/roles.go b/detect/roles.go new file mode 100644 index 0000000..d9996fd --- /dev/null +++ b/detect/roles.go @@ -0,0 +1,62 @@ +package detect + +import ( + "path/filepath" + "strings" + + "github.com/git-pkgs/roles" +) + +func (e *Engine) pathRoles(rel string) roles.Set { + if labels, ok := e.fileRoles[rel]; ok { + return labels + } + local := filepath.ToSlash(e.pathAtAnalysisRoot(rel)) + if strings.HasSuffix(rel, "/") { + local += "/" + } + labels, err := roles.Match(local) + if err != nil { + return 0 + } + return labels +} + +func (e *Engine) projectEvidence(rel string) bool { + labels := e.pathRoles(rel) + return !labels.Has(roles.Vendor) && !labels.Has(roles.Fixture) && + !labels.Has(roles.Cache) && !labels.Has(roles.BuildOutput) +} + +func (e *Engine) languageEvidence(rel string) bool { + if !e.projectEvidence(rel) { + return false + } + labels := e.pathRoles(rel) + return !labels.Has(roles.Generated) && !labels.Has(roles.Example) && + !labels.Has(roles.Test) && !labels.Has(roles.Benchmark) && + !labels.Has(roles.Fuzz) && !labels.Has(roles.Documentation) && + !labels.Has(roles.Tooling) +} + +func (e *Engine) sourceEvidence(rel string) bool { + if !e.languageEvidence(rel) { + return false + } + labels := e.pathRoles(rel) + // Unclassified paths include root files and unconventional source directories. + return (labels == 0 || labels.Has(roles.Source)) && + !labels.Has(roles.Packaging) && !labels.Has(roles.Configuration) && + !labels.Has(roles.Build) && !labels.Has(roles.CI) && !labels.Has(roles.Legal) +} + +func (e *Engine) languageFileExists(pattern string) bool { + e.filesChecked++ + e.loadProjectFiles() + for _, rel := range e.projectFiles { + if e.languageEvidence(rel) && e.matchesProjectPattern(pattern, rel) { + return true + } + } + return false +} diff --git a/detect/roles_test.go b/detect/roles_test.go new file mode 100644 index 0000000..79a254c --- /dev/null +++ b/detect/roles_test.go @@ -0,0 +1,147 @@ +package detect + +import ( + "path/filepath" + "slices" + "testing" +) + +func TestLanguageEvidenceByRole(t *testing.T) { + t.Setenv("PATH", "") + for _, name := range []string{ + "evals/fixtures/sqli/app.py", + "skills/semgrep/scripts/scan.py", + "docs/conf.py", + "examples/client/app.py", + "samples/client/app.py", + "spec/app.py", + "features/app.py", + "src/test_app.py", + "src/service_pb2.py", + "src/generated/client.py", + "src/vendor/app.py", + "bower_components/app.py", + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "main.go", "package main\n") + writeProjectFile(t, dir, name, "print('fixture')\n") + r := runOn(t, dir) + if got := languageNames(r); !slices.Equal(got, []string{"Go"}) { + t.Errorf("languages = %v, want only Go", got) + } + }) + } +} + +func TestLanguageRankingExcludesTestAndGeneratedFiles(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + for _, name := range []string{"main.go", "internal/a_test.go", "internal/b_test.go", "service.pb.go", "zz_generated_types.go"} { + writeProjectFile(t, dir, name, "package main\n") + } + writeProjectFile(t, dir, "app.py", "print('app')\n") + writeProjectFile(t, dir, "worker.py", "print('worker')\n") + if got := languageNames(runOn(t, dir)); !slices.Equal(got, []string{"Python", "Go"}) { + t.Errorf("languages = %v, want Python then Go", got) + } +} + +func TestLanguageRankingDoesNotCountManifestsAsSource(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + writeProjectFile(t, dir, "main.go", "package main\n") + for _, name := range []string{"parser.gemspec", "lexer.gemspec", "src/parser.gemspec", "src/lexer.gemspec"} { + writeProjectFile(t, dir, name, "Gem::Specification.new do |spec|\n spec.name = 'parser'\nend\n") + } + if got := languageNames(runOn(t, dir)); !slices.Equal(got, []string{"Go", "Ruby"}) { + t.Errorf("languages = %v, want Go source before Ruby manifests", got) + } +} + +func TestRoleFilteringPreservesManifestLanguageAndTestTools(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + writeProjectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n") + writeProjectFile(t, dir, "spec/app_spec.rb", "describe 'app' do\nend\n") + r := runOn(t, dir) + if got := languageNames(r); !slices.Equal(got, []string{"Ruby"}) { + t.Errorf("languages = %v, want Ruby from Gemfile", got) + } + assertToolDetected(t, r, "test", "RSpec") +} + +func TestRoleFilteringPreservesRubyManifestEvidence(t *testing.T) { + t.Setenv("PATH", "") + for name, content := range map[string]string{ + "parser.gemspec": "Gem::Specification.new do |spec|\n spec.name = 'parser'\n spec.version = '0.1.0'\nend\n", + "gems.rb": "source 'https://rubygems.org'\n", + "Rakefile": "require 'hoe'\nHoe.spec 'parser'\n", + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeProjectFile(t, root, "Gemfile", "source 'https://rubygems.org'\ngem 'rubocop'\n") + writeProjectFile(t, root, "packages/parser/"+name, content) + r := runOn(t, filepath.Join(root, "packages", "parser")) + if got := languageNames(r); !slices.Equal(got, []string{"Ruby"}) { + t.Errorf("languages = %v, want Ruby from %s", got, name) + } + assertToolNotDetected(t, r, "lint", "RuboCop") + }) + } +} + +func TestRoleFilteringToolAndDependencyEvidence(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + writeProjectFile(t, dir, "Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[workspace]\nmembers = [\"fixtures/*\", \"packages/*\"]\n") + for _, name := range []string{"fixtures/demo/Cargo.toml", "bower_components/demo/Cargo.toml"} { + writeProjectFile(t, dir, name, "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n[dependencies]\naxum = \"0.8\"\n") + } + writeProjectFile(t, dir, "packages/core/Cargo.toml", "[package]\nname = \"core\"\nversion = \"0.1.0\"\n[dependencies]\nserde = \"1\"\n") + writeProjectFile(t, dir, "Cargo.lock", "version = 3\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n") + writeProjectFile(t, dir, "fixtures/deploy.yaml", "apiVersion: argoproj.io/v1alpha1\nkind: Application\n") + r := runOn(t, dir) + for _, detections := range r.Tools { + for _, tool := range detections { + if tool.Name == "Axum" || tool.Name == "Argo CD" { + t.Errorf("fixture triggered %s", tool.Name) + } + } + } + var foundMember, foundLock bool + for _, manifest := range r.Manifests { + switch manifest.Path { + case "fixtures/demo/Cargo.toml", "bower_components/demo/Cargo.toml": + t.Errorf("unexpected manifest %s", manifest.Path) + case "packages/core/Cargo.toml": + foundMember = true + case "Cargo.lock": + foundLock = true + } + } + if !foundMember || !foundLock { + t.Errorf("workspace member or lockfile missing: %+v", r.Manifests) + } + for _, dep := range r.Dependencies { + if dep.Name == "axum" { + t.Error("fixture dependency included in report") + } + } +} + +func TestRoleFilteringStyleAndFlatLayout(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + writeProjectFile(t, dir, "service/main.go", "package main\nfunc main() {\n\tprintln(1)\n}\n") + for _, name := range []string{"aaa/types.pb.go", "aaa/main_test.go", "fixtures/main.go", "examples/main.go", "scripts/main.go"} { + writeProjectFile(t, dir, name, "package main\r\nfunc main() {\r\n println(1)\r\n println(2)\r\n}\r\n") + } + r := runOn(t, dir) + if r.Style == nil || r.Style.Indentation != "tabs" || r.Style.LineEnding != "LF" { + t.Errorf("style = %+v, want tabs and LF from source", r.Style) + } + if r.Layout == nil || !slices.Equal(r.Layout.SourceDirs, []string{"service"}) { + t.Errorf("layout = %+v, want service as the only source directory", r.Layout) + } +} diff --git a/go.mod b/go.mod index 8a29305..b0afb70 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/git-pkgs/outline v0.2.2 github.com/git-pkgs/purl v0.1.20 github.com/git-pkgs/registries v0.9.2 + github.com/git-pkgs/roles v0.1.3 github.com/git-pkgs/spdx v0.3.2 github.com/klauspost/compress v1.20.0 github.com/ulikunitz/xz v0.5.17 diff --git a/go.sum b/go.sum index 92f7c17..9f64d7f 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/git-pkgs/purl v0.1.20 h1:a4qzvUy5mBZ2GGjOQNW2h/ocFqjTjOiTDMv2ONtivmM= github.com/git-pkgs/purl v0.1.20/go.mod h1:hthV5mp+Q67HpQ9+LnRLLmsReu5ooyQ5EaJsCGrA8yE= github.com/git-pkgs/registries v0.9.2 h1:YWn//VGIbwoVYpxNFt1UgS3A6oA0nGiWde23l5R5Z5g= github.com/git-pkgs/registries v0.9.2/go.mod h1:BpXlfRCfu4S5bTzm7u7igppVu94mXgll/PENe0AjKmI= +github.com/git-pkgs/roles v0.1.3 h1:BkXoBFg0/4szCvBes8mD+UApRe1jITaZ/4bs+XtRxdw= +github.com/git-pkgs/roles v0.1.3/go.mod h1:USQTDlh22S7jNyAxU5SwtMfXjbX0lyJpVsGLTccyYis= github.com/git-pkgs/spdx v0.3.2 h1:PV0w09Bt8rIkAof1+zuo3vxNYKgCoFTxQY9E1Q85PL0= github.com/git-pkgs/spdx v0.3.2/go.mod h1:n9rAicgw+Wqtfursju6oKaAL6cUCTiIS926mb1KWoKU= github.com/git-pkgs/vers v0.7.1 h1:23VceCbOjQKib0jhbfNRqL3YqkBPiUwdkQNhJoZgnsU=