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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions cmd/brief/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
50 changes: 32 additions & 18 deletions detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -47,6 +48,7 @@ const (
categoryDocs = "docs"
categoryFormat = "format"
categoryLint = "lint"
categoryLanguage = "language"
categoryTest = "test"
categoryTypecheck = "typecheck"
lineCounterSCC = "scc"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]++
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions detect/roles.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading