From fa853e79df62eb2ebb40a12fd32e215cdda02c19 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 31 Jul 2026 10:30:40 +0100 Subject: [PATCH] Support globs in file_contains --- detect/detect.go | 81 +++++++++++++++++++++++++++++++++++++++++-- detect/detect_test.go | 77 ++++++++++++++++++++++++++++++++++++++++ detect/filter.go | 11 ++++-- detect/filter_test.go | 32 +++++++++++++++++ 4 files changed, 197 insertions(+), 4 deletions(-) diff --git a/detect/detect.go b/detect/detect.go index e872565..fc46a49 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -646,13 +646,65 @@ func (e *Engine) safeReadFile(file string) ([]byte, error) { return io.ReadAll(f) } -// contains checks if a file contains any of the given strings. +// contains checks if an exact file or any regular file matching a glob contains +// one of the given strings. func (e *Engine) contains(file string, patterns []string) bool { + if kb.HasGlobPattern(file) { + return e.globContains(file, patterns) + } + data, err := e.safeReadFile(file) if err != nil { return false } - content := string(data) + return containsAny(string(data), patterns) +} + +func (e *Engine) globContains(pattern string, contentPatterns []string) bool { + found := false + errFound := errors.New("found") + _ = filepath.WalkDir(e.Root, func(filePath string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + + rel, err := filepath.Rel(e.Root, filePath) + if err != nil { + return nil + } + if d.IsDir() { + if rel != "." && e.shouldSkipDir(d.Name()) { + return filepath.SkipDir + } + if e.ScanDepth > 0 && rel != "." && + strings.Count(rel, string(filepath.Separator))+1 > e.ScanDepth { + return filepath.SkipDir + } + if !e.isTracked(rel) { + return filepath.SkipDir + } + return nil + } + + if !e.isTracked(rel) || !matchPathPattern(pattern, filepath.ToSlash(rel)) { + return nil + } + info, err := d.Info() + if err != nil || !info.Mode().IsRegular() { + return nil + } + + data, err := e.safeReadFile(rel) + if err != nil || !containsAny(string(data), contentPatterns) { + return nil + } + found = true + return errFound + }) + return found +} + +func containsAny(content string, patterns []string) bool { for _, p := range patterns { if strings.Contains(content, p) { return true @@ -661,6 +713,31 @@ func (e *Engine) contains(file string, patterns []string) bool { return false } +// matchPathPattern matches slash-separated paths and treats ** as zero or more +// complete path segments. +func matchPathPattern(pattern, name string) bool { + pattern = filepath.ToSlash(pattern) + name = filepath.ToSlash(name) + return matchPathSegments(strings.Split(pattern, "/"), strings.Split(name, "/")) +} + +func matchPathSegments(pattern, name []string) bool { + if len(pattern) == 0 { + return len(name) == 0 + } + if pattern[0] == "**" { + if matchPathSegments(pattern[1:], name) { + return true + } + return len(name) > 0 && matchPathSegments(pattern, name[1:]) + } + if len(name) == 0 { + return false + } + matched, err := path.Match(pattern[0], name[0]) + return err == nil && matched && matchPathSegments(pattern[1:], name[1:]) +} + // loadDeps parses all manifest files in the project using the manifests library // and populates the dependency caches. Called lazily on first dependency check. func (e *Engine) loadDeps() { diff --git a/detect/detect_test.go b/detect/detect_test.go index 7fec1d7..96c172c 100644 --- a/detect/detect_test.go +++ b/detect/detect_test.go @@ -1213,6 +1213,83 @@ func TestGlobIgnoresDirectories(t *testing.T) { } } +func TestFileContainsGlob(t *testing.T) { + const marker = "native extension marker" + + t.Run("root file", func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "example.gemspec", marker) + + if !New(loadKB(t), dir).contains("**/*.gemspec", []string{marker}) { + t.Error("expected **/*.gemspec to inspect a root file") + } + }) + + t.Run("nested file", func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "lib/example.ex", marker) + + if !New(loadKB(t), dir).contains("**/*.ex", []string{marker}) { + t.Error("expected **/*.ex to inspect a nested file") + } + }) + + t.Run("multiple matches", func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "src/a.rs", "near miss") + writeProjectFile(t, dir, "src/b.rs", marker) + + engine := New(loadKB(t), dir) + tool := &kb.ToolDef{ + Detect: kb.DetectInfo{ + FileContains: map[string][]string{"**/*.rs": {marker}}, + }, + } + if got := engine.matchTool(tool); got != brief.ConfidenceHigh { + t.Errorf("glob-backed file_contains confidence = %q, want %q", got, brief.ConfidenceHigh) + } + }) + + t.Run("skipped directory", func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "vendor/native.rs", marker) + + if New(loadKB(t), dir).contains("**/*.rs", []string{marker}) { + t.Error("file_contains glob should not inspect skipped directories") + } + }) + + t.Run("regular files only", func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "native.rs"), 0o755); err != nil { + t.Fatal(err) + } + + if New(loadKB(t), dir).contains("**/*.rs", []string{marker}) { + t.Error("file_contains glob should not inspect directories") + } + }) + + t.Run("near miss", func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "src/native.rs", "no matching content") + writeProjectFile(t, dir, "notes.txt", marker) + + if New(loadKB(t), dir).contains("**/*.rs", []string{marker}) { + t.Error("content in a non-matching file should not satisfy file_contains") + } + }) + + t.Run("exact path", func(t *testing.T) { + dir := t.TempDir() + writeProjectFile(t, dir, "config/tool.toml", marker) + + if !New(loadKB(t), dir).contains("config/tool.toml", []string{marker}) { + t.Error("exact file_contains path should retain its existing behavior") + } + }) +} + func TestDirectoryGlobPattern(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "Foo.xcodeproj"), 0o755); err != nil { diff --git a/detect/filter.go b/detect/filter.go index fef0cbf..d66d351 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -314,10 +314,17 @@ func matchesDetectionPatterns(tool *kb.ToolDef, changed map[string]bool, changed } func matchesContentTargets(tool *kb.ToolDef, changed map[string]bool) bool { - for file := range tool.Detect.FileContains { - if changed[file] { + for pattern := range tool.Detect.FileContains { + if changed[pattern] { return true } + if kb.HasGlobPattern(pattern) { + for file := range changed { + if matchPathPattern(pattern, file) { + return true + } + } + } } for file := range tool.Detect.KeyExists { if changed[file] { diff --git a/detect/filter_test.go b/detect/filter_test.go index 882308c..22a6d9a1 100644 --- a/detect/filter_test.go +++ b/detect/filter_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/git-pkgs/brief" + "github.com/git-pkgs/brief/kb" ) func TestFilterResources(t *testing.T) { @@ -163,6 +164,37 @@ func TestFilterByChangedFiles_Tools(t *testing.T) { } } +func TestToolMatchesChangedFiles_FileContainsGlob(t *testing.T) { + tool := &kb.ToolDef{ + Detect: kb.DetectInfo{ + FileContains: map[string][]string{ + "**/*.rs": {"native extension marker"}, + "config/tool.toml": {"enabled = true"}, + }, + }, + } + + tests := []struct { + name string + changed string + want bool + }{ + {name: "root glob match", changed: "lib.rs", want: true}, + {name: "nested glob match", changed: "native/example/src/lib.rs", want: true}, + {name: "exact path", changed: "config/tool.toml", want: true}, + {name: "near miss", changed: "native/example/src/lib.go", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed := map[string]bool{tt.changed: true} + if got := toolMatchesChangedFiles(tool, changed, nil); got != tt.want { + t.Errorf("toolMatchesChangedFiles(%q) = %v, want %v", tt.changed, got, tt.want) + } + }) + } +} + func TestFilterByChangedFiles_PackageManagers(t *testing.T) { knowledgeBase := loadKB(t)