From 4b3447296c32569742eae563cdad95e09351eaf4 Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Mon, 23 Mar 2026 23:29:38 -0400 Subject: [PATCH 1/9] feat: add directory-scoped matching with trailing-slash patterns and directory queries --- cmd/sctx/main.go | 49 +++- internal/core/engine.go | 259 ++++++++++++++++- internal/core/engine_test.go | 539 +++++++++++++++++++++++++++++++++++ internal/core/schema.go | 3 +- 4 files changed, 826 insertions(+), 24 deletions(-) diff --git a/cmd/sctx/main.go b/cmd/sctx/main.go index 38d4df9..67bc108 100644 --- a/cmd/sctx/main.go +++ b/cmd/sctx/main.go @@ -19,8 +19,8 @@ const usage = `sctx — Structured Context CLI Usage: sctx hook Read agent hook input from stdin, return matching context sctx context [--on ] [--when ] [--json] - Query context entries for a file - sctx decisions [--json] Query decisions for a file + Query context entries for a file or directory + sctx decisions [--json] Query decisions for a file or directory sctx validate [] Validate all context files in a directory tree sctx init Create a starter AGENTS.yaml in the current directory sctx claude enable Enable sctx hooks in Claude Code @@ -148,11 +148,18 @@ func cmdContext(args []string, out, errOut io.Writer) error { return fmt.Errorf("resolving path: %w", err) } - result, warnings, err := core.Resolve(core.ResolveRequest{ - FilePath: absPath, - Action: action, - Timing: timing, - }) + req := core.ResolveRequest{ + Action: action, + Timing: timing, + } + + if isDir(absPath, filePath) { + req.DirPath = absPath + } else { + req.FilePath = absPath + } + + result, warnings, err := core.Resolve(req) if err != nil { return err } @@ -199,11 +206,18 @@ func cmdDecisions(args []string, out, errOut io.Writer) error { return fmt.Errorf("resolving path: %w", err) } - result, warnings, err := core.Resolve(core.ResolveRequest{ - FilePath: absPath, - Action: core.ActionAll, - Timing: core.TimingBefore, - }) + req := core.ResolveRequest{ + Action: core.ActionAll, + Timing: core.TimingBefore, + } + + if isDir(absPath, filePath) { + req.DirPath = absPath + } else { + req.FilePath = absPath + } + + result, warnings, err := core.Resolve(req) if err != nil { return err } @@ -331,6 +345,17 @@ decisions: return nil } +// isDir reports whether the path refers to a directory. +// It checks if the path exists as a directory on disk, or ends with a path separator. +func isDir(absPath, originalPath string) bool { + info, err := os.Stat(absPath) //nolint:gosec // absPath is derived from filepath.Abs, not user-controlled taint + if err == nil && info.IsDir() { + return true + } + + return strings.HasSuffix(originalPath, "/") || strings.HasSuffix(originalPath, string(filepath.Separator)) +} + func cmdClaude(args []string) error { if len(args) < 1 { return errClaudeSubcommand diff --git a/internal/core/engine.go b/internal/core/engine.go index 95a39e7..5740bc5 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -10,21 +10,24 @@ import ( "gopkg.in/yaml.v3" ) +var errMutuallyExclusive = fmt.Errorf("FilePath and DirPath are mutually exclusive") + // AgentsFileNames are the recognized filenames, in priority order. var AgentsFileNames = []string{ "AGENTS.yaml", "AGENTS.yml", } -// Resolve finds all context and decisions that apply to a file for a given -// action and timing. This is the primary entry point for the core engine. +// Resolve finds all context and decisions that apply to a file or directory +// for a given action and timing. This is the primary entry point for the core engine. +// Set FilePath for file queries, DirPath for directory queries. They are mutually exclusive. func Resolve(req ResolveRequest) (*ResolveResult, []string, error) { - absPath, err := filepath.Abs(req.FilePath) - if err != nil { - return nil, nil, fmt.Errorf("resolving absolute path: %w", err) + if req.FilePath != "" && req.DirPath != "" { + return nil, nil, errMutuallyExclusive } root := req.Root + var err error if root == "" { root, err = os.Getwd() if err != nil { @@ -32,6 +35,19 @@ func Resolve(req ResolveRequest) (*ResolveResult, []string, error) { } } + if req.DirPath != "" { + return resolveDir(req, root) + } + + return resolveFile(req, root) +} + +func resolveFile(req ResolveRequest, root string) (*ResolveResult, []string, error) { + absPath, err := filepath.Abs(req.FilePath) + if err != nil { + return nil, nil, fmt.Errorf("resolving absolute path: %w", err) + } + files, warnings := discoverAndParse(filepath.Dir(absPath), root) result := &ResolveResult{} @@ -47,6 +63,29 @@ func Resolve(req ResolveRequest) (*ResolveResult, []string, error) { return result, warnings, nil } +func resolveDir(req ResolveRequest, root string) (*ResolveResult, []string, error) { + absDir, err := filepath.Abs(req.DirPath) + if err != nil { + return nil, nil, fmt.Errorf("resolving absolute path: %w", err) + } + + // For directory queries, start discovery from the directory itself, + // not its parent (which is what filepath.Dir would give us for a file). + files, warnings := discoverAndParse(absDir, root) + + result := &ResolveResult{} + + for _, cf := range files { + matchedCtx := filterContextDir(cf, absDir, req.Action, req.Timing) + result.ContextEntries = append(result.ContextEntries, matchedCtx...) + + matchedDec := filterDecisionsDir(cf, absDir) + result.DecisionEntries = append(result.DecisionEntries, matchedDec...) + } + + return result, warnings, nil +} + // discoverAndParse walks from startDir up to root, collecting and parsing all // context files. Files from parent directories come first (lower specificity). func discoverAndParse(startDir, root string) (files []ContextFile, warnings []string) { @@ -129,7 +168,33 @@ func filterContext(cf ContextFile, absPath string, action Action, timing Timing) var matched []MatchedContext for _, entry := range cf.Context { - if !matchesGlobs(cf.sourceDir, absPath, entry.Match, entry.Exclude) { + if !matchesFileGlobs(cf.sourceDir, absPath, entry.Match, entry.Exclude) { + continue + } + + if !matchesAction(entry.On, action) { + continue + } + + if timing != TimingAll && Timing(entry.When) != TimingAll && Timing(entry.When) != timing { + continue + } + + matched = append(matched, MatchedContext{ + Content: entry.Content, + SourceDir: cf.sourceDir, + }) + } + + return matched +} + +// filterContextDir returns context entries from cf that match the given directory, action, and timing. +func filterContextDir(cf ContextFile, absDir string, action Action, timing Timing) []MatchedContext { + var matched []MatchedContext + + for _, entry := range cf.Context { + if !matchesDirGlobs(cf.sourceDir, absDir, entry.Match, entry.Exclude) { continue } @@ -155,7 +220,22 @@ func filterDecisions(cf ContextFile, absPath string) []DecisionEntry { var matched []DecisionEntry for _, entry := range cf.Decisions { - if !matchesGlobs(cf.sourceDir, absPath, entry.Match, nil) { + if !matchesFileGlobs(cf.sourceDir, absPath, entry.Match, nil) { + continue + } + + matched = append(matched, entry) + } + + return matched +} + +// filterDecisionsDir returns decision entries from cf that match the given directory. +func filterDecisionsDir(cf ContextFile, absDir string) []DecisionEntry { + var matched []DecisionEntry + + for _, entry := range cf.Decisions { + if !matchesDirGlobs(cf.sourceDir, absDir, entry.Match, nil) { continue } @@ -165,18 +245,22 @@ func filterDecisions(cf ContextFile, absPath string) []DecisionEntry { return matched } -// matchesGlobs checks if absPath matches any of the match patterns and none of the exclude patterns. +// isDirPattern reports whether a glob pattern targets a directory (ends with /). +func isDirPattern(pattern string) bool { + return strings.HasSuffix(pattern, "/") +} + +// matchesFileGlobs checks if absPath matches any of the match patterns and none of the exclude patterns. +// Directory patterns (trailing /) are skipped — they never match file queries. // Globs are resolved relative to sourceDir. -func matchesGlobs(sourceDir, absPath string, match, exclude []string) bool { +func matchesFileGlobs(sourceDir, absPath string, match, exclude []string) bool { relPath, err := filepath.Rel(sourceDir, absPath) if err != nil { return false } - // Normalize to forward slashes for consistent glob matching. relPath = filepath.ToSlash(relPath) - // Don't match files outside this directory tree. if strings.HasPrefix(relPath, "..") { return false } @@ -184,6 +268,10 @@ func matchesGlobs(sourceDir, absPath string, match, exclude []string) bool { matched := false for _, pattern := range match { + if isDirPattern(pattern) { + continue // directory patterns don't match files + } + ok, matchErr := doublestar.Match(pattern, relPath) if matchErr != nil { continue @@ -200,6 +288,10 @@ func matchesGlobs(sourceDir, absPath string, match, exclude []string) bool { } for _, pattern := range exclude { + if isDirPattern(pattern) { + continue + } + ok, matchErr := doublestar.Match(pattern, relPath) if matchErr != nil { continue @@ -213,6 +305,151 @@ func matchesGlobs(sourceDir, absPath string, match, exclude []string) bool { return true } +// matchesDirGlobs checks if absDir matches any of the match patterns for a directory query. +// For directory patterns (trailing /), the directory must match exactly. +// For file-glob patterns, the pattern must be capable of matching files inside the directory. +// Globs are resolved relative to sourceDir. +func matchesDirGlobs(sourceDir, absDir string, match, exclude []string) bool { + relDir, err := filepath.Rel(sourceDir, absDir) + if err != nil { + return false + } + + relDir = filepath.ToSlash(relDir) + + if strings.HasPrefix(relDir, "..") { + return false + } + + // "." means the directory is the sourceDir itself. + if relDir == "." { + relDir = "" + } + + if !anyDirPatternMatches(relDir, match) { + return false + } + + return !anyDirPatternMatches(relDir, exclude) +} + +// anyDirPatternMatches reports whether any pattern in the list matches the directory. +func anyDirPatternMatches(relDir string, patterns []string) bool { + for _, pattern := range patterns { + if dirPatternMatches(relDir, pattern) { + return true + } + } + + return false +} + +// dirPatternMatches checks a single pattern against a directory. +func dirPatternMatches(relDir, pattern string) bool { + if isDirPattern(pattern) { + return dirSlashPatternMatches(relDir, pattern) + } + + return fileGlobMatchesDir(pattern, relDir) +} + +// dirSlashPatternMatches checks a trailing-slash pattern against a directory path. +func dirSlashPatternMatches(relDir, pattern string) bool { + dirWithSlash := relDir + "/" + if relDir == "" { + dirWithSlash = "./" + } + + ok, err := doublestar.Match(pattern, dirWithSlash) + + return err == nil && ok +} + +// fileGlobMatchesDir reports whether a file-glob pattern could match files inside relDir. +// For example, "tests/**" matches directory "tests", "**/*.py" matches any directory, +// and "src/**" does not match directory "tests". +func fileGlobMatchesDir(pattern, relDir string) bool { + // Pattern "**" or "**/*" matches any directory. + if pattern == "**" || pattern == "**/*" { + return true + } + + // If the pattern starts with "**/" it can match at any depth, + // so it potentially matches any directory. + if strings.HasPrefix(pattern, "**/") { + return true + } + + // If the queried directory is the sourceDir itself (relDir is empty), + // any pattern could match files directly in it. + if relDir == "" { + return true + } + + // Split both the pattern and the directory into segments and walk them + // together. A pattern segment can be a literal ("src"), a wildcard ("*"), + // or a recursive wildcard ("**"). We need to determine whether files + // matching this pattern could exist inside relDir. + patParts := strings.Split(pattern, "/") + dirParts := strings.Split(relDir, "/") + + return dirCouldContainMatch(patParts, dirParts) +} + +// dirCouldContainMatch reports whether a directory (dirParts) could contain files +// matching the given pattern (patParts). Both are split by "/". +// +// The key insight: we walk the pattern and directory segments together. +// If the directory is deeper than the pattern's directory portion, we need "**" +// somewhere in the pattern to bridge the gap. If the directory is shallower, +// the pattern's deeper segments might produce files under the directory. +func dirCouldContainMatch(patParts, dirParts []string) bool { + return matchSegments(patParts, dirParts, 0, 0) +} + +func matchSegments(pat, dir []string, pi, di int) bool { + for pi < len(pat) && di < len(dir) { + p := pat[pi] + + if p == "**" { + // "**" can match zero or more directory segments. + // Try consuming 0..len(dir)-di segments from dir. + for skip := 0; skip <= len(dir)-di; skip++ { + if matchSegments(pat, dir, pi+1, di+skip) { + return true + } + } + return false + } + + // Check if this pattern segment matches the directory segment. + ok, err := doublestar.Match(p, dir[di]) + if err != nil || !ok { + return false + } + + pi++ + di++ + } + + if di == len(dir) { + // We've consumed all directory segments. The pattern still has remaining + // segments (pi < len(pat)). Those remaining segments would match files + // inside this directory, so the pattern is relevant. + // + // But if we also consumed the entire pattern (pi == len(pat)), the pattern + // was fully used up matching directory segments. There's nothing left to + // match files, so the pattern doesn't produce hits *inside* the directory. + // Example: pattern "foo/*", dir "foo/bar" -> pattern consumed matching + // "foo" and "bar", nothing left to match files in foo/bar/. + return pi < len(pat) + } + + // If we've consumed all pattern segments but there are directory segments left, + // the directory is deeper than the pattern reaches. No match. + return false +} + // matchesAction checks if the requested action is included in the entry's on list. func matchesAction(on FlexList, action Action) bool { if action == ActionAll { diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index e6b678c..7d6bfcc 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1054,6 +1054,545 @@ decisions: }) } +// --- Directory query tests --- + +func TestResolve_DirQuery_TrailingSlashPattern(t *testing.T) { + tmpDir := t.TempDir() + testsDir := filepath.Join(tmpDir, "tests") + if err := os.MkdirAll(testsDir, 0o750); err != nil { + t.Fatal(err) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "dir-scoped to tests" + match: ["tests/"] + on: all + when: before + - content: "file-scoped to tests" + match: ["tests/**"] + on: all + when: before +`) + + // Directory query for tests/ should match the trailing-slash pattern. + result, _, err := Resolve(ResolveRequest{ + DirPath: testsDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + assertContextContents(t, result.ContextEntries, []string{ + "dir-scoped to tests", + "file-scoped to tests", + }) +} + +func TestResolve_DirQuery_TrailingSlashDoesNotMatchSubdir(t *testing.T) { + tmpDir := t.TempDir() + testsDir := filepath.Join(tmpDir, "tests") + unitDir := filepath.Join(testsDir, "unit") + if err := os.MkdirAll(unitDir, 0o750); err != nil { + t.Fatal(err) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "dir-scoped to tests" + match: ["tests/"] + on: all + when: before +`) + + // Directory query for tests/unit/ should NOT match "tests/". + result, _, err := Resolve(ResolveRequest{ + DirPath: unitDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if len(result.ContextEntries) != 0 { + t.Errorf("expected 0 entries for subdir query, got %d: %v", + len(result.ContextEntries), result.ContextEntries) + } +} + +func TestResolve_DirQuery_TrailingSlashDoesNotMatchFileQuery(t *testing.T) { + tmpDir := t.TempDir() + testsDir := filepath.Join(tmpDir, "tests") + if err := os.MkdirAll(testsDir, 0o750); err != nil { + t.Fatal(err) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "dir-scoped to tests" + match: ["tests/"] + on: all + when: before +`) + + target := filepath.Join(testsDir, "conftest.py") + writeTestFile(t, target, "") + + // File query should NOT match "tests/" pattern. + result, _, err := Resolve(ResolveRequest{ + FilePath: target, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if len(result.ContextEntries) != 0 { + t.Errorf("expected 0 entries for file query against dir pattern, got %d", len(result.ContextEntries)) + } +} + +func TestResolve_DirQuery_GlobWithDoubleStarSlash(t *testing.T) { + tmpDir := t.TempDir() + fooTests := filepath.Join(tmpDir, "foo", "bar", "tests") + bazTests := filepath.Join(tmpDir, "foo", "baz", "tests") + fooBar := filepath.Join(tmpDir, "foo", "bar") + for _, d := range []string{fooTests, bazTests} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "any tests dir under foo" + match: ["foo/**/tests/"] + on: all + when: before +`) + + tests := []struct { + name string + dir string + wantN int + }{ + {"foo/bar/tests matches", fooTests, 1}, + {"foo/baz/tests matches", bazTests, 1}, + {"foo/bar does not match", fooBar, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if len(result.ContextEntries) != tt.wantN { + t.Errorf("expected %d entries, got %d", tt.wantN, len(result.ContextEntries)) + } + }) + } +} + +func TestResolve_DirQuery_FileGlobMatchesContainingDir(t *testing.T) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + testsDir := filepath.Join(tmpDir, "tests") + for _, d := range []string{srcDir, testsDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "all python files" + match: ["**/*.py"] + on: all + when: before + - content: "src only" + match: ["src/**"] + on: all + when: before +`) + + tests := []struct { + name string + dir string + want []string + }{ + {"src gets both", srcDir, []string{"all python files", "src only"}}, + {"tests gets wildcard only", testsDir, []string{"all python files"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + assertContextContents(t, result.ContextEntries, tt.want) + }) + } +} + +func TestResolve_DirQuery_DecisionsWithTrailingSlash(t *testing.T) { + tmpDir := t.TempDir() + apiDir := filepath.Join(tmpDir, "src", "api") + modelsDir := filepath.Join(tmpDir, "src", "models") + for _, d := range []string{apiDir, modelsDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +decisions: + - decision: "REST over GraphQL" + rationale: "Team expertise" + match: ["src/api/"] + - decision: "PostgreSQL over DynamoDB" + rationale: "JSONB support" +`) + + // api dir gets both (dir-scoped + default **) + result, _, err := Resolve(ResolveRequest{ + DirPath: apiDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if len(result.DecisionEntries) != 2 { + t.Fatalf("expected 2 decisions for api dir, got %d", len(result.DecisionEntries)) + } + + // models dir only gets the default ** decision + result, _, err = Resolve(ResolveRequest{ + DirPath: modelsDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if len(result.DecisionEntries) != 1 { + t.Fatalf("expected 1 decision for models dir, got %d", len(result.DecisionEntries)) + } + if result.DecisionEntries[0].Decision != "PostgreSQL over DynamoDB" { + t.Errorf("wrong decision: %q", result.DecisionEntries[0].Decision) + } +} + +func TestResolve_DirQuery_MutuallyExclusive(t *testing.T) { + _, _, err := Resolve(ResolveRequest{ + FilePath: "/some/file.go", + DirPath: "/some/dir", + Root: "/some", + }) + if err == nil { + t.Fatal("expected error when both FilePath and DirPath are set") + } +} + +func TestResolve_DirQuery_SelfDirectory(t *testing.T) { + // Query the directory that contains the AGENTS.yaml itself. + tmpDir := t.TempDir() + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "applies to everything" + on: all + when: before +decisions: + - decision: "some decision" + rationale: "some reason" +`) + + result, _, err := Resolve(ResolveRequest{ + DirPath: tmpDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + assertContextContents(t, result.ContextEntries, []string{"applies to everything"}) + if len(result.DecisionEntries) != 1 { + t.Fatalf("expected 1 decision, got %d", len(result.DecisionEntries)) + } +} + +func TestResolve_DirQuery_ParentMergesWithChild(t *testing.T) { + tmpDir := t.TempDir() + childDir := filepath.Join(tmpDir, "child") + if err := os.MkdirAll(childDir, 0o750); err != nil { + t.Fatal(err) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "from parent" +`) + writeTestFile(t, filepath.Join(childDir, "AGENTS.yaml"), ` +context: + - content: "from child" +`) + + result, _, err := Resolve(ResolveRequest{ + DirPath: childDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + assertContextContents(t, result.ContextEntries, []string{"from parent", "from child"}) +} + +func TestResolve_DirQuery_ActionAndTimingFiltering(t *testing.T) { + tmpDir := t.TempDir() + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "edit-before" + on: edit + when: before + - content: "read-after" + on: read + when: after + - content: "all-all" + on: all + when: all +`) + + result, _, err := Resolve(ResolveRequest{ + DirPath: tmpDir, + Action: ActionEdit, + Timing: TimingBefore, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + assertContextContents(t, result.ContextEntries, []string{"edit-before", "all-all"}) +} + +func TestResolve_DirQuery_ExcludeWithTrailingSlash(t *testing.T) { + tmpDir := t.TempDir() + vendorDir := filepath.Join(tmpDir, "vendor") + srcDir := filepath.Join(tmpDir, "src") + for _, d := range []string{vendorDir, srcDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "everywhere except vendor" + exclude: ["vendor/"] +`) + + // src should match + result, _, err := Resolve(ResolveRequest{ + DirPath: srcDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertContextContents(t, result.ContextEntries, []string{"everywhere except vendor"}) + + // vendor should be excluded + result, _, err = Resolve(ResolveRequest{ + DirPath: vendorDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != 0 { + t.Errorf("expected 0 entries for vendor dir, got %d", len(result.ContextEntries)) + } +} + +func TestResolve_DirQuery_SingleStarPattern(t *testing.T) { + tmpDir := t.TempDir() + fooDir := filepath.Join(tmpDir, "foo") + fooBarDir := filepath.Join(tmpDir, "foo", "bar") + for _, d := range []string{fooDir, fooBarDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "direct children only" + match: ["foo/*"] +`) + + // foo/ should match (foo/* can match files in foo/) + result, _, err := Resolve(ResolveRequest{ + DirPath: fooDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertContextContents(t, result.ContextEntries, []string{"direct children only"}) + + // foo/bar/ should NOT match (foo/* only matches direct children) + result, _, err = Resolve(ResolveRequest{ + DirPath: fooBarDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != 0 { + t.Errorf("expected 0 entries for foo/bar/, got %d", len(result.ContextEntries)) + } +} + +func TestResolve_DirQuery_NeverPanics(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + tmpDir := t.TempDir() + + depth := rapid.IntRange(0, 3).Draw(rt, "depth") + dir := tmpDir + + for i := range depth { + dir = filepath.Join(dir, genDirName(rt)) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + numEntries := rapid.IntRange(0, 4).Draw(rt, "numEntries") + var entries []ContextEntry + + for j := range numEntries { + numMatch := rapid.IntRange(0, 2).Draw(rt, "numMatch") + var match []string + for range numMatch { + // Include directory patterns in the mix. + if rapid.Bool().Draw(rt, "isDirPattern") { + match = append(match, genGlob(rt)+"/") + } else { + match = append(match, genGlob(rt)) + } + } + + entries = append(entries, ContextEntry{ + Content: fmt.Sprintf("content-%d-%d", i, j), + Match: match, + On: FlexList{genOnValue(rt)}, + When: genWhenValue(rt), + }) + } + + if len(entries) > 0 { + writeAgentsYAML(t, dir, entries) + } + } + + action := genAction(rt) + timing := genTiming(rt) + + // Must not panic. + _, _, _ = Resolve(ResolveRequest{ + DirPath: dir, + Action: action, + Timing: timing, + Root: tmpDir, + }) + }) +} + +func TestResolve_DirQuery_DeepPatternFromParentDir(t *testing.T) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + apiDir := filepath.Join(tmpDir, "src", "api") + handlersDir := filepath.Join(tmpDir, "src", "api", "handlers") + otherDir := filepath.Join(tmpDir, "other") + for _, d := range []string{handlersDir, otherDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "handler context" + match: ["src/api/handlers/*.py"] +`) + + tests := []struct { + name string + dir string + wantN int + }{ + {"handlers dir matches", handlersDir, 1}, + {"api dir matches (pattern goes through it)", apiDir, 1}, + {"src dir matches (pattern goes through it)", srcDir, 1}, + {"other dir does not match", otherDir, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if len(result.ContextEntries) != tt.wantN { + t.Errorf("expected %d entries, got %d", tt.wantN, len(result.ContextEntries)) + } + }) + } +} + // assertContextContents checks that the matched context entries have exactly // the expected content strings, in order. func assertContextContents(t *testing.T, got []MatchedContext, want []string) { diff --git a/internal/core/schema.go b/internal/core/schema.go index eac6b52..06d7bb8 100644 --- a/internal/core/schema.go +++ b/internal/core/schema.go @@ -79,7 +79,8 @@ const ( // ResolveRequest contains the universal inputs for context resolution. // This is the agent-agnostic interface between adapters and the core engine. type ResolveRequest struct { - FilePath string + FilePath string // Resolve for a specific file. Mutually exclusive with DirPath. + DirPath string // Resolve for a directory. Mutually exclusive with FilePath. Action Action Timing Timing Root string // Project root directory; walk stops here. From cb8e7d62e7ec81df4723a2b2fdaae793a96c337b Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Mon, 23 Mar 2026 23:39:55 -0400 Subject: [PATCH 2/9] updating docs --- README.md | 8 ++++---- docs/cli-reference.md | 8 ++++++-- docs/context.md | 29 +++++++++++++++++++++++++++-- docs/decisions.md | 15 ++++++++++++++- docs/examples.md | 4 ++++ docs/getting-started.md | 3 ++- docs/protocol.md | 17 +++++++++++++---- 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bc760ce..10d0111 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Parent directory context merges with child directory context. Entries from paren Each entry has a `content` string and optional filters: -- **match** - glob patterns for files this applies to (default: `["**"]`, everything) +- **match** - glob patterns for files or directories this applies to (default: `["**"]`, everything). Patterns ending with `/` target directories. - **exclude** - glob patterns to skip - **on** - when the file is being `read`, `edit`ed, `create`d, or `all` (default) - **when** - deliver `before` or `after` the file content in the prompt, or `all` for both (default: `before`) @@ -183,9 +183,9 @@ Response without New Zealand reference. **sctx hook** - Reads agent hook input from stdin, returns matching context entries. This is the main integration point. Decisions are excluded from hook output. -**sctx context \** - Query context entries for a file. Supports `--on `, `--when `, and `--json`. +**sctx context \** - Query context entries for a file or directory. Supports `--on `, `--when `, and `--json`. -**sctx decisions \** - Query decisions for a file. Supports `--json`. +**sctx decisions \** - Query decisions for a file or directory. Supports `--json`. **sctx validate [\]** - Checks all context files in a directory tree for schema errors and invalid globs. @@ -232,6 +232,6 @@ sctx pi disable ## Agent-agnostic design -The core engine knows nothing about Claude Code, pi, or any other agent. It takes a file path, an action, and a timing, and returns matched context. Agent-specific bits live in thin adapter layers that translate stdin JSON into those universal inputs. +The core engine knows nothing about Claude Code, pi, or any other agent. It takes a file or directory path, an action, and a timing, and returns matched context. Agent-specific bits live in thin adapter layers that translate stdin JSON into those universal inputs. Other agents can use `sctx context` directly, or new adapters can be added without touching the core. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ad003cd..9a2c642 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -23,11 +23,14 @@ The Write tool gets special treatment: `sctx` checks whether the target file exi ## sctx context \ -Query context entries for a file. Useful for debugging and testing your context files. The current working directory is used as the project root — only `AGENTS.yaml` files at or below it are considered. +Query context entries for a file or directory. Useful for debugging and testing your context files. The current working directory is used as the project root — only `AGENTS.yaml` files at or below it are considered. + +If the path is a directory (or ends with `/`), sctx runs a directory query. Directory patterns like `match: ["tests/"]` only match directory queries, not file queries. File-glob patterns like `match: ["**/*.py"]` match a directory query if they could produce hits inside that directory. ```bash sctx context src/api/handler.py sctx context src/api/handler.py --on edit --when before +sctx context src/api/ # directory query sctx context src/api/handler.py --json ``` @@ -41,10 +44,11 @@ sctx context src/api/handler.py --json ## sctx decisions \ -Query decisions for a file. Shows architectural decisions that apply based on glob matching. +Query decisions for a file or directory. Shows architectural decisions that apply based on glob matching. Directory queries work the same way as `sctx context` -- pass a directory path to see decisions scoped to that directory. ```bash sctx decisions src/api/handler.py +sctx decisions src/api/ # directory query sctx decisions src/api/handler.py --json ``` diff --git a/docs/context.md b/docs/context.md index d109fa2..8d39145 100644 --- a/docs/context.md +++ b/docs/context.md @@ -22,8 +22,8 @@ When an agent edits a Python file, it sees that instruction. When it edits a SQL | Field | Type | Required | Default | Description | |---|---|---|---|---| | `content` | string | yes | -- | The guidance to deliver | -| `match` | list of globs | no | `["**"]` | File patterns this applies to | -| `exclude` | list of globs | no | `[]` | File patterns to skip | +| `match` | list of globs | no | `["**"]` | File or directory patterns this applies to | +| `exclude` | list of globs | no | `[]` | File or directory patterns to skip | | `on` | string or list | no | `all` | Action filter: `read`, `edit`, `create`, or `all` | | `when` | string | no | `before` | Prompt positioning: `before`, `after`, or `all` | @@ -81,6 +81,31 @@ exclude: ["**/vendor/**"] The default match is `["**"]` (recursive, everything). `exclude` is applied after `match`. A file must match at least one `match` pattern and zero `exclude` patterns. +### Directory patterns + +A pattern ending with `/` targets a directory instead of files. This follows the same convention as `.gitignore` and `rsync`. + +```yaml +# Applies to the tests/ directory itself, not its subdirectories +match: ["tests/"] + +# Matches any tests/ directory anywhere under src/ +match: ["src/**/tests/"] + +# Matches any directory named api/ at any depth +match: ["**/api/"] +``` + +Directory patterns never match file queries. They only match when an agent (or the CLI) queries a directory directly: + +```bash +sctx context tests/ # directory query -- tests/ pattern matches +sctx context tests/unit/ # directory query -- tests/ pattern does NOT match +sctx context tests/foo.py # file query -- tests/ pattern does NOT match +``` + +This gives you precision that recursive file globs can't. A decision like "test fixtures live in conftest.py at this level" can target `tests/` without leaking into `tests/unit/` or `tests/integration/`. + ## on What the agent is doing with the file. diff --git a/docs/decisions.md b/docs/decisions.md index a5956e8..9072285 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -26,7 +26,7 @@ The most expensive agent mistake isn't writing bad code. It's confidently propos | `alternatives` | list | no | -- | Options that were considered and rejected | | `revisit_when` | string | no | -- | Condition under which this should be reconsidered | | `date` | date | no | -- | When it was made (YYYY-MM-DD) | -| `match` | list of globs | no | `["**"]` | Scope to specific files | +| `match` | list of globs | no | `["**"]` | Scope to specific files or directories | ```yaml decisions: @@ -117,4 +117,17 @@ decisions: This decision only shows up when an agent is working in `src/api/`. It won't clutter context for someone editing frontend code. +### Directory-scoped decisions + +A pattern ending with `/` targets a directory without leaking into its subdirectories. This is useful when a decision applies to a specific level of the project but not to everything underneath it. + +```yaml +decisions: + - decision: "Test fixtures live in conftest.py at this level" + rationale: "Subdirectories import from here, don't duplicate fixtures" + match: ["tests/"] +``` + +This decision shows up when querying `tests/` but not when querying `tests/unit/` or editing `tests/unit/test_thing.py`. Full glob syntax works too: `match: ["**/tests/"]` targets any `tests/` directory at any depth. + See [Examples](examples.md) for complete `AGENTS.yaml` files showing decisions alongside context entries in real projects. diff --git a/docs/examples.md b/docs/examples.md index 2d27b77..603b7d2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -307,6 +307,10 @@ decisions: revisit_when: "Stripe pricing becomes prohibitive or we need multi-PSP" date: 2025-08-20 match: ["**/*.ts"] + + - decision: "Webhook handlers in this package, not in the API gateway" + rationale: "Payment webhooks need access to payment domain logic for validation" + match: ["./"] # scoped to this directory only, not inherited by subdirectories ``` When an agent edits `packages/payments/src/checkout.ts`, it gets the shared monorepo conventions *and* the payments-specific context. The payments context appears last, giving it stronger influence. diff --git a/docs/getting-started.md b/docs/getting-started.md index 57e82fb..7529787 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -92,10 +92,11 @@ You can also test from the command line: sctx context README.md --on read --when before ``` -Check what decisions apply: +Check what decisions apply to a file or directory: ```bash sctx decisions src/main.py +sctx decisions src/api/ # directory query ``` Validate all context files in your project: diff --git a/docs/protocol.md b/docs/protocol.md index 589eb79..26f2d45 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -58,8 +58,8 @@ See [Context entries](context.md) and [Decisions](decisions.md) for field detail | Field | Type | Required | Default | Description | |---|---|---|---|---| | `content` | string | yes | -- | The guidance to deliver | -| `match` | list of globs | no | `["**"]` | File patterns this applies to | -| `exclude` | list of globs | no | `[]` | File patterns to skip | +| `match` | list of globs | no | `["**"]` | File or directory patterns this applies to | +| `exclude` | list of globs | no | `[]` | File or directory patterns to skip | | `on` | string or list | no | `all` | Action filter: `read`, `edit`, `create`, `all` | | `when` | string | no | `before` | Prompt positioning: `before`, `after`, `all` | @@ -72,20 +72,29 @@ See [Context entries](context.md) and [Decisions](decisions.md) for field detail | `alternatives` | list | no | -- | Rejected options and constraints | | `revisit_when` | string | no | -- | Condition to reconsider | | `date` | date | no | -- | When decided (YYYY-MM-DD) | -| `match` | list of globs | no | `["**"]` | Scope to specific files | +| `match` | list of globs | no | `["**"]` | Scope to specific files or directories | ## Resolution algorithm +### File queries + Given a file path, an action, and a timing: 1. **Discover** -- Walk from the target file's directory up to the project root, collecting all context files at each level 2. **Parse** -- Parse each file. Emit warnings for invalid files but continue processing valid ones -3. **Filter by match/exclude** -- Test each entry's glob patterns against the target file path. Globs are relative to the context file's directory +3. **Filter by match/exclude** -- Test each entry's glob patterns against the target file path. Globs are relative to the context file's directory. Directory patterns (trailing `/`) are skipped during file queries. 4. **Filter by action** -- Keep entries where `on` includes the requested action (or is `all`) 5. **Filter by timing** -- Keep entries where `when` matches the requested timing 6. **Merge** -- Combine all matching entries. Parent directory entries come first, child directory entries come last 7. **Return** -- The ordered list of matching context entries and decisions +### Directory queries + +Given a directory path, an action, and a timing. The algorithm is the same with two differences: + +- **Discovery starts from the directory itself**, not its parent. This ensures entries in the queried directory's own `AGENTS.yaml` are included. +- **Matching handles two pattern types.** Directory patterns (trailing `/`) match if the queried directory matches the pattern exactly. File-glob patterns match if they could produce hits inside the queried directory (e.g. `src/**` matches a query for `src/` but not for `tests/`). + ## Merge order Parent directories come before child directories. From 4cf11f8196945fa2eab02fc1a37019c3bbc83e83 Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Mon, 23 Mar 2026 23:57:56 -0400 Subject: [PATCH 3/9] addressing bot feedback --- cmd/sctx/main.go | 6 +- internal/core/engine.go | 73 ++++++++----- internal/core/engine_test.go | 204 ++++++++++++++++++++++++++++++----- 3 files changed, 227 insertions(+), 56 deletions(-) diff --git a/cmd/sctx/main.go b/cmd/sctx/main.go index 67bc108..30875a7 100644 --- a/cmd/sctx/main.go +++ b/cmd/sctx/main.go @@ -347,12 +347,14 @@ decisions: // isDir reports whether the path refers to a directory. // It checks if the path exists as a directory on disk, or ends with a path separator. +// When the path doesn't exist, it falls back to the trailing slash convention. func isDir(absPath, originalPath string) bool { info, err := os.Stat(absPath) //nolint:gosec // absPath is derived from filepath.Abs, not user-controlled taint - if err == nil && info.IsDir() { - return true + if err == nil { + return info.IsDir() } + // Path doesn't exist on disk. Use trailing slash as the signal. return strings.HasSuffix(originalPath, "/") || strings.HasSuffix(originalPath, string(filepath.Separator)) } diff --git a/internal/core/engine.go b/internal/core/engine.go index 5740bc5..7820d1f 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -1,6 +1,7 @@ package core import ( + "errors" "fmt" "os" "path/filepath" @@ -10,7 +11,7 @@ import ( "gopkg.in/yaml.v3" ) -var errMutuallyExclusive = fmt.Errorf("FilePath and DirPath are mutually exclusive") +var errMutuallyExclusive = errors.New("FilePath and DirPath are mutually exclusive") // AgentsFileNames are the recognized filenames, in priority order. var AgentsFileNames = []string{ @@ -374,12 +375,6 @@ func fileGlobMatchesDir(pattern, relDir string) bool { return true } - // If the pattern starts with "**/" it can match at any depth, - // so it potentially matches any directory. - if strings.HasPrefix(pattern, "**/") { - return true - } - // If the queried directory is the sourceDir itself (relDir is empty), // any pattern could match files directly in it. if relDir == "" { @@ -404,49 +399,75 @@ func fileGlobMatchesDir(pattern, relDir string) bool { // somewhere in the pattern to bridge the gap. If the directory is shallower, // the pattern's deeper segments might produce files under the directory. func dirCouldContainMatch(patParts, dirParts []string) bool { - return matchSegments(patParts, dirParts, 0, 0) + return matchSegments(patParts, dirParts, 0, 0, false) } -func matchSegments(pat, dir []string, pi, di int) bool { +// matchSegments walks pattern and directory segments together to determine if +// the pattern could match files inside the directory. +// +// literalMatched tracks whether at least one non-"**" pattern segment has been +// successfully matched against a real directory segment. This matters at the +// terminal case: if no literals ever matched, the directory is not "on the +// pattern's path" and remaining literal segments are unvalidated guesses. +func matchSegments(pat, dir []string, pi, di int, literalMatched bool) bool { for pi < len(pat) && di < len(dir) { p := pat[pi] if p == "**" { + // If "**" is the last segment, it matches zero or more directories + // and any files below. The directory always matches. + if pi == len(pat)-1 { + return true + } + // "**" can match zero or more directory segments. - // Try consuming 0..len(dir)-di segments from dir. for skip := 0; skip <= len(dir)-di; skip++ { - if matchSegments(pat, dir, pi+1, di+skip) { + if matchSegments(pat, dir, pi+1, di+skip, literalMatched) { return true } } + return false } - // Check if this pattern segment matches the directory segment. + // Literal or wildcard segment: must match the directory segment. ok, err := doublestar.Match(p, dir[di]) if err != nil || !ok { return false } + literalMatched = true pi++ di++ } if di == len(dir) { - // We've consumed all directory segments. The pattern still has remaining - // segments (pi < len(pat)). Those remaining segments would match files - // inside this directory, so the pattern is relevant. - // - // But if we also consumed the entire pattern (pi == len(pat)), the pattern - // was fully used up matching directory segments. There's nothing left to - // match files, so the pattern doesn't produce hits *inside* the directory. - // Example: pattern "foo/*", dir "foo/bar" -> pattern consumed matching - // "foo" and "bar", nothing left to match files in foo/bar/. - return pi < len(pat) - } - - // If we've consumed all pattern segments but there are directory segments left, - // the directory is deeper than the pattern reaches. No match. + // All directory segments consumed. Check remaining pattern. + remaining := pat[pi:] + + if len(remaining) == 0 { + // Pattern fully consumed matching directories. Nothing left for files. + return false + } + + // If at least one literal was validated against a real dir segment, + // the directory is on the pattern's path. Remaining segments describe + // a subpath from here that could contain matching files. + if literalMatched { + return true + } + + // No literals were ever validated (pattern started with "**" and "**" + // consumed all dir segments). Remaining segments are unvalidated. + // Only match if remaining is a single filename segment or starts with "**". + if len(remaining) == 1 { + return true + } + + return remaining[0] == "**" + } + + // Pattern exhausted but directory segments remain. Pattern doesn't reach. return false } diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index 7d6bfcc..f6f9e27 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1421,30 +1421,30 @@ context: exclude: ["vendor/"] `) - // src should match - result, _, err := Resolve(ResolveRequest{ - DirPath: srcDir, - Action: ActionAll, - Timing: TimingAll, - Root: tmpDir, - }) - if err != nil { - t.Fatalf("Resolve() error: %v", err) + tests := []struct { + name string + dir string + wantN int + }{ + {"src/ not excluded", srcDir, 1}, + {"vendor/ excluded", vendorDir, 0}, } - assertContextContents(t, result.ContextEntries, []string{"everywhere except vendor"}) - // vendor should be excluded - result, _, err = Resolve(ResolveRequest{ - DirPath: vendorDir, - Action: ActionAll, - Timing: TimingAll, - Root: tmpDir, - }) - if err != nil { - t.Fatalf("Resolve() error: %v", err) - } - if len(result.ContextEntries) != 0 { - t.Errorf("expected 0 entries for vendor dir, got %d", len(result.ContextEntries)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != tt.wantN { + t.Errorf("expected %d entries, got %d", tt.wantN, len(result.ContextEntries)) + } + }) } } @@ -1545,13 +1545,16 @@ func TestResolve_DirQuery_NeverPanics(t *testing.T) { }) } -func TestResolve_DirQuery_DeepPatternFromParentDir(t *testing.T) { +func TestResolve_DirQuery_FileGlobDepthMatching(t *testing.T) { + // Tests that file-glob patterns correctly match directories at various depths, + // including the trailing-** regression (src/** must match src/api/). tmpDir := t.TempDir() srcDir := filepath.Join(tmpDir, "src") apiDir := filepath.Join(tmpDir, "src", "api") handlersDir := filepath.Join(tmpDir, "src", "api", "handlers") + testsDir := filepath.Join(tmpDir, "tests") otherDir := filepath.Join(tmpDir, "other") - for _, d := range []string{handlersDir, otherDir} { + for _, d := range []string{handlersDir, testsDir, otherDir} { if err := os.MkdirAll(d, 0o750); err != nil { t.Fatal(err) } @@ -1559,19 +1562,74 @@ func TestResolve_DirQuery_DeepPatternFromParentDir(t *testing.T) { writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` context: + - content: "src everything" + match: ["src/**"] - content: "handler context" match: ["src/api/handlers/*.py"] `) + tests := []struct { + name string + dir string + wantNames []string + }{ + {"src/ gets both", srcDir, []string{"src everything", "handler context"}}, + {"src/api/ gets both", apiDir, []string{"src everything", "handler context"}}, + {"src/api/handlers/ gets both", handlersDir, []string{"src everything", "handler context"}}, + {"tests/ gets neither", testsDir, nil}, + {"other/ gets neither", otherDir, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != len(tt.wantNames) { + got := make([]string, len(result.ContextEntries)) + for i, e := range result.ContextEntries { + got[i] = e.Content + } + t.Errorf("expected %d entries %v, got %d %v", + len(tt.wantNames), tt.wantNames, len(result.ContextEntries), got) + } + }) + } +} + +func TestResolve_DirQuery_ExcludeFileGlobPattern(t *testing.T) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + vendorDir := filepath.Join(tmpDir, "vendor") + vendorDeepDir := filepath.Join(tmpDir, "vendor", "github.com", "pkg") + srcVendorDir := filepath.Join(tmpDir, "src", "vendor") + for _, d := range []string{srcDir, vendorDeepDir, srcVendorDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "everywhere except vendor" + exclude: ["**/vendor/**"] +`) + tests := []struct { name string dir string wantN int }{ - {"handlers dir matches", handlersDir, 1}, - {"api dir matches (pattern goes through it)", apiDir, 1}, - {"src dir matches (pattern goes through it)", srcDir, 1}, - {"other dir does not match", otherDir, 0}, + {"src/ not excluded", srcDir, 1}, + {"vendor/ excluded", vendorDir, 0}, + {"vendor/deep excluded", vendorDeepDir, 0}, + {"src/vendor/ excluded", srcVendorDir, 0}, } for _, tt := range tests { @@ -1585,7 +1643,97 @@ context: if err != nil { t.Fatalf("Resolve() error: %v", err) } + if len(result.ContextEntries) != tt.wantN { + t.Errorf("expected %d entries, got %d", tt.wantN, len(result.ContextEntries)) + } + }) + } +} +func TestResolve_DirQuery_DoubleStarMiddlePattern(t *testing.T) { + tmpDir := t.TempDir() + srcTests := filepath.Join(tmpDir, "src", "api", "tests") + srcApi := filepath.Join(tmpDir, "src", "api") + srcDir := filepath.Join(tmpDir, "src") + otherTests := filepath.Join(tmpDir, "other", "tests") + for _, d := range []string{srcTests, otherTests} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "src test files" + match: ["src/**/tests/*.py"] +`) + + tests := []struct { + name string + dir string + wantN int + }{ + {"src/api/tests/ matches", srcTests, 1}, + {"src/api/ matches (pattern goes through)", srcApi, 1}, + {"src/ matches (pattern starts here)", srcDir, 1}, + {"other/tests/ does not match (wrong prefix)", otherTests, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != tt.wantN { + t.Errorf("expected %d entries, got %d", tt.wantN, len(result.ContextEntries)) + } + }) + } +} + +func TestResolve_DirQuery_DoubleStarPrefixMatchIsSelective(t *testing.T) { + // Patterns like **/api/*.py should match directories containing api/ but not all directories. + tmpDir := t.TempDir() + apiDir := filepath.Join(tmpDir, "src", "api") + modelsDir := filepath.Join(tmpDir, "src", "models") + for _, d := range []string{apiDir, modelsDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "api handlers" + match: ["**/api/*.py"] +`) + + tests := []struct { + name string + dir string + wantN int + }{ + {"src/api/ matches", apiDir, 1}, + {"src/models/ does not match", modelsDir, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } if len(result.ContextEntries) != tt.wantN { t.Errorf("expected %d entries, got %d", tt.wantN, len(result.ContextEntries)) } From dbf6bdfc9c7ca99d7f68bd0aa89c7412c7d655be Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Tue, 24 Mar 2026 00:08:12 -0400 Subject: [PATCH 4/9] addressing second round of bot feedback --- docs/cli-reference.md | 2 +- internal/core/engine.go | 157 +++++++++++++++++++++++++---------- internal/core/engine_test.go | 85 ++++++++++++++++++- 3 files changed, 193 insertions(+), 51 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9a2c642..069c016 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -25,7 +25,7 @@ The Write tool gets special treatment: `sctx` checks whether the target file exi Query context entries for a file or directory. Useful for debugging and testing your context files. The current working directory is used as the project root — only `AGENTS.yaml` files at or below it are considered. -If the path is a directory (or ends with `/`), sctx runs a directory query. Directory patterns like `match: ["tests/"]` only match directory queries, not file queries. File-glob patterns like `match: ["**/*.py"]` match a directory query if they could produce hits inside that directory. +If the path exists on disk as a directory, sctx automatically runs a directory query. For paths that don't exist on disk, append a trailing `/` to force a directory query. Directory patterns like `match: ["tests/"]` only match directory queries, not file queries. File-glob patterns like `match: ["**/*.py"]` match a directory query if they could produce hits inside that directory. ```bash sctx context src/api/handler.py diff --git a/internal/core/engine.go b/internal/core/engine.go index 7820d1f..0f57c92 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -310,6 +310,9 @@ func matchesFileGlobs(sourceDir, absPath string, match, exclude []string) bool { // For directory patterns (trailing /), the directory must match exactly. // For file-glob patterns, the pattern must be capable of matching files inside the directory. // Globs are resolved relative to sourceDir. +// +// Match and exclude use different strictness levels. Match is generous (extra context +// is acceptable). Exclude is strict (must not remove context that should be shown). func matchesDirGlobs(sourceDir, absDir string, match, exclude []string) bool { relDir, err := filepath.Rel(sourceDir, absDir) if err != nil { @@ -322,7 +325,6 @@ func matchesDirGlobs(sourceDir, absDir string, match, exclude []string) bool { return false } - // "." means the directory is the sourceDir itself. if relDir == "." { relDir = "" } @@ -331,13 +333,18 @@ func matchesDirGlobs(sourceDir, absDir string, match, exclude []string) bool { return false } - return !anyDirPatternMatches(relDir, exclude) + return !anyDirPatternExcludes(relDir, exclude) } -// anyDirPatternMatches reports whether any pattern in the list matches the directory. +// anyDirPatternMatches reports whether any match pattern applies to the directory. +// Uses generous matching. func anyDirPatternMatches(relDir string, patterns []string) bool { for _, pattern := range patterns { - if dirPatternMatches(relDir, pattern) { + if isDirPattern(pattern) { + if dirSlashPatternMatches(relDir, pattern) { + return true + } + } else if fileGlobMatchesDir(pattern, relDir) { return true } } @@ -345,13 +352,20 @@ func anyDirPatternMatches(relDir string, patterns []string) bool { return false } -// dirPatternMatches checks a single pattern against a directory. -func dirPatternMatches(relDir, pattern string) bool { - if isDirPattern(pattern) { - return dirSlashPatternMatches(relDir, pattern) +// anyDirPatternExcludes reports whether any exclude pattern applies to the directory. +// Uses strict matching to avoid over-excluding. +func anyDirPatternExcludes(relDir string, patterns []string) bool { + for _, pattern := range patterns { + if isDirPattern(pattern) { + if dirSlashPatternMatches(relDir, pattern) { + return true + } + } else if fileGlobExcludesDir(pattern, relDir) { + return true + } } - return fileGlobMatchesDir(pattern, relDir) + return false } // dirSlashPatternMatches checks a trailing-slash pattern against a directory path. @@ -367,62 +381,121 @@ func dirSlashPatternMatches(relDir, pattern string) bool { } // fileGlobMatchesDir reports whether a file-glob pattern could match files inside relDir. -// For example, "tests/**" matches directory "tests", "**/*.py" matches any directory, -// and "src/**" does not match directory "tests". +// This is used for match evaluation and is intentionally generous: if the pattern +// could possibly produce hits inside the directory, it returns true. Extra context +// is acceptable; missing context is not. func fileGlobMatchesDir(pattern, relDir string) bool { - // Pattern "**" or "**/*" matches any directory. if pattern == "**" || pattern == "**/*" { return true } - // If the queried directory is the sourceDir itself (relDir is empty), - // any pattern could match files directly in it. + // Any pattern could match files in the sourceDir itself. if relDir == "" { return true } - // Split both the pattern and the directory into segments and walk them - // together. A pattern segment can be a literal ("src"), a wildcard ("*"), - // or a recursive wildcard ("**"). We need to determine whether files - // matching this pattern could exist inside relDir. + // Patterns starting with **/ can match at any depth, so they're relevant + // to any directory. + if strings.HasPrefix(pattern, "**/") { + return true + } + patParts := strings.Split(pattern, "/") dirParts := strings.Split(relDir, "/") return dirCouldContainMatch(patParts, dirParts) } +// fileGlobExcludesDir reports whether a file-glob exclude pattern should exclude relDir. +// This is stricter than fileGlobMatchesDir: it only returns true when the directory +// is clearly within the exclude pattern's scope. This prevents patterns like +// "vendor/**" from excluding the root directory, and "**/vendor/**" from +// excluding every directory. +func fileGlobExcludesDir(pattern, relDir string) bool { + if pattern == "**" || pattern == "**/*" { + return true + } + + if relDir == "" { + // Only exclude the root for patterns that genuinely target everything. + // Patterns like "vendor/**" don't target the root. + return false + } + + patParts := strings.Split(pattern, "/") + dirParts := strings.Split(relDir, "/") + + return dirCouldExclude(patParts, dirParts) +} + // dirCouldContainMatch reports whether a directory (dirParts) could contain files // matching the given pattern (patParts). Both are split by "/". -// -// The key insight: we walk the pattern and directory segments together. -// If the directory is deeper than the pattern's directory portion, we need "**" -// somewhere in the pattern to bridge the gap. If the directory is shallower, -// the pattern's deeper segments might produce files under the directory. +// Used for match evaluation (generous). func dirCouldContainMatch(patParts, dirParts []string) bool { - return matchSegments(patParts, dirParts, 0, 0, false) + return matchSegments(patParts, dirParts, 0, 0) } -// matchSegments walks pattern and directory segments together to determine if -// the pattern could match files inside the directory. -// -// literalMatched tracks whether at least one non-"**" pattern segment has been -// successfully matched against a real directory segment. This matters at the -// terminal case: if no literals ever matched, the directory is not "on the -// pattern's path" and remaining literal segments are unvalidated guesses. -func matchSegments(pat, dir []string, pi, di int, literalMatched bool) bool { +// dirCouldExclude reports whether a directory should be excluded by the pattern. +// Stricter than dirCouldContainMatch: requires that at least one literal segment +// in the pattern has been validated against an actual directory segment. +// This prevents "**/vendor/**" from excluding directories that don't contain "vendor" +// in their path. +func dirCouldExclude(patParts, dirParts []string) bool { + return matchSegmentsStrict(patParts, dirParts, 0, 0, false) +} + +// matchSegments walks pattern and directory segments to determine if the pattern +// could produce file matches inside the directory. This is the generous version +// used for match evaluation. +func matchSegments(pat, dir []string, pi, di int) bool { + for pi < len(pat) && di < len(dir) { + p := pat[pi] + + if p == "**" { + if pi == len(pat)-1 { + return true + } + + for skip := 0; skip <= len(dir)-di; skip++ { + if matchSegments(pat, dir, pi+1, di+skip) { + return true + } + } + + return false + } + + ok, err := doublestar.Match(p, dir[di]) + if err != nil || !ok { + return false + } + + pi++ + di++ + } + + if di == len(dir) { + return pi < len(pat) + } + + return false +} + +// matchSegmentsStrict is the strict version of matchSegments, used for exclude +// evaluation. It tracks whether at least one non-"**" pattern segment was matched +// against a real directory segment. If not, remaining literal segments are +// considered unvalidated and the match is rejected. +func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) bool { for pi < len(pat) && di < len(dir) { p := pat[pi] if p == "**" { - // If "**" is the last segment, it matches zero or more directories - // and any files below. The directory always matches. if pi == len(pat)-1 { return true } - // "**" can match zero or more directory segments. for skip := 0; skip <= len(dir)-di; skip++ { - if matchSegments(pat, dir, pi+1, di+skip, literalMatched) { + if matchSegmentsStrict(pat, dir, pi+1, di+skip, literalMatched) { return true } } @@ -430,7 +503,6 @@ func matchSegments(pat, dir []string, pi, di int, literalMatched bool) bool { return false } - // Literal or wildcard segment: must match the directory segment. ok, err := doublestar.Match(p, dir[di]) if err != nil || !ok { return false @@ -442,24 +514,18 @@ func matchSegments(pat, dir []string, pi, di int, literalMatched bool) bool { } if di == len(dir) { - // All directory segments consumed. Check remaining pattern. remaining := pat[pi:] if len(remaining) == 0 { - // Pattern fully consumed matching directories. Nothing left for files. return false } - // If at least one literal was validated against a real dir segment, - // the directory is on the pattern's path. Remaining segments describe - // a subpath from here that could contain matching files. if literalMatched { return true } - // No literals were ever validated (pattern started with "**" and "**" - // consumed all dir segments). Remaining segments are unvalidated. - // Only match if remaining is a single filename segment or starts with "**". + // No literals validated. Only match for single filename segment + // or remaining starting with **. if len(remaining) == 1 { return true } @@ -467,7 +533,6 @@ func matchSegments(pat, dir []string, pi, di int, literalMatched bool) bool { return remaining[0] == "**" } - // Pattern exhausted but directory segments remain. Pattern doesn't reach. return false } diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index f6f9e27..d2815b6 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1697,9 +1697,13 @@ context: } } -func TestResolve_DirQuery_DoubleStarPrefixMatchIsSelective(t *testing.T) { - // Patterns like **/api/*.py should match directories containing api/ but not all directories. +func TestResolve_DirQuery_DoubleStarPrefixMatch(t *testing.T) { + // **/api/*.py matches any directory because ** can bridge to any depth. + // src/models/api/foo.py is a valid match, so src/models/ should match. + // The pattern is generous for match: if files matching the pattern could + // exist anywhere under the directory, it matches. tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") apiDir := filepath.Join(tmpDir, "src", "api") modelsDir := filepath.Join(tmpDir, "src", "models") for _, d := range []string{apiDir, modelsDir} { @@ -1720,7 +1724,8 @@ context: wantN int }{ {"src/api/ matches", apiDir, 1}, - {"src/models/ does not match", modelsDir, 0}, + {"src/models/ matches (could contain api/ subdir)", modelsDir, 1}, + {"src/ matches", srcDir, 1}, } for _, tt := range tests { @@ -1741,7 +1746,79 @@ context: } } -// assertContextContents checks that the matched context entries have exactly +func TestResolve_DirQuery_ExcludeDoesNotPoisonRoot(t *testing.T) { + // Querying the directory that contains the AGENTS.yaml (relDir=="") + // must not be excluded by file-glob exclude patterns. + tmpDir := t.TempDir() + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "not vendor" + exclude: ["vendor/**"] + - content: "not generated" + exclude: ["**/generated/**"] +`) + + result, _, err := Resolve(ResolveRequest{ + DirPath: tmpDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + assertContextContents(t, result.ContextEntries, []string{"not vendor", "not generated"}) +} + +func TestResolve_DirQuery_ExcludeStrictVsMatchGenerous(t *testing.T) { + // match is generous: **/vendor/** matches any directory (vendor/ could be nested). + // exclude is strict: **/vendor/** only excludes directories with "vendor" in their path. + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + vendorDir := filepath.Join(tmpDir, "vendor") + for _, d := range []string{srcDir, vendorDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + // match uses **/vendor/** — should match src/ (generous) + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "vendor-scoped" + match: ["**/vendor/**"] + - content: "everything except vendor" + exclude: ["**/vendor/**"] +`) + + // src/ should get "vendor-scoped" (generous match) and "everything except vendor" (not excluded) + result, _, err := Resolve(ResolveRequest{ + DirPath: srcDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertContextContents(t, result.ContextEntries, []string{"vendor-scoped", "everything except vendor"}) + + // vendor/ should get "vendor-scoped" (match) but NOT "everything except vendor" (excluded) + result, _, err = Resolve(ResolveRequest{ + DirPath: vendorDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertContextContents(t, result.ContextEntries, []string{"vendor-scoped"}) +} + +// assertContextContents checks// assertContextContents checks that the matched context entries have exactly // the expected content strings, in order. func assertContextContents(t *testing.T, got []MatchedContext, want []string) { t.Helper() From e407a3beafd036dfc368a30eea63de49acbfaafc Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Tue, 24 Mar 2026 00:41:51 -0400 Subject: [PATCH 5/9] addressing bot suggestions with better bot --- docs/context.md | 2 ++ internal/core/engine.go | 38 +++++++++++++++++++++++++++++++++--- internal/core/engine_test.go | 12 +++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/context.md b/docs/context.md index 8d39145..bf4b3d2 100644 --- a/docs/context.md +++ b/docs/context.md @@ -96,6 +96,8 @@ match: ["src/**/tests/"] match: ["**/api/"] ``` +When a file-glob pattern (like `**/*.py`) appears in a directory query, `match` and `exclude` use different strictness levels. Match is **generous**: if the pattern *could* produce hits inside the directory, the entry is included. Exclude is **strict**: it only removes the directory when the pattern clearly targets it. This means `exclude: ["**/vendor/**"]` won't exclude `src/` (good — vendor files aren't in `src/`), but `match: ["**/vendor/**"]` *will* match `src/` (acceptable — it errs on the side of showing extra context). The asymmetry prevents accidental over-exclusion. + Directory patterns never match file queries. They only match when an agent (or the CLI) queries a directory directly: ```bash diff --git a/internal/core/engine.go b/internal/core/engine.go index 0f57c92..93b9fd6 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -11,7 +11,10 @@ import ( "gopkg.in/yaml.v3" ) -var errMutuallyExclusive = errors.New("FilePath and DirPath are mutually exclusive") +var ( + errMutuallyExclusive = errors.New("FilePath and DirPath are mutually exclusive") + errPathRequired = errors.New("FilePath or DirPath is required") +) // AgentsFileNames are the recognized filenames, in priority order. var AgentsFileNames = []string{ @@ -27,6 +30,10 @@ func Resolve(req ResolveRequest) (*ResolveResult, []string, error) { return nil, nil, errMutuallyExclusive } + if req.FilePath == "" && req.DirPath == "" { + return nil, nil, errPathRequired + } + root := req.Root var err error if root == "" { @@ -428,11 +435,28 @@ func fileGlobExcludesDir(pattern, relDir string) bool { return dirCouldExclude(patParts, dirParts) } +// collapseDoubleStars removes consecutive "**" segments from a pattern. +// Multiple adjacent "**" segments are semantically equivalent to a single "**" +// but cause exponential branching in the recursive matcher. +func collapseDoubleStars(parts []string) []string { + out := make([]string, 0, len(parts)) + + for _, p := range parts { + if p == "**" && len(out) > 0 && out[len(out)-1] == "**" { + continue + } + + out = append(out, p) + } + + return out +} + // dirCouldContainMatch reports whether a directory (dirParts) could contain files // matching the given pattern (patParts). Both are split by "/". // Used for match evaluation (generous). func dirCouldContainMatch(patParts, dirParts []string) bool { - return matchSegments(patParts, dirParts, 0, 0) + return matchSegments(collapseDoubleStars(patParts), dirParts, 0, 0) } // dirCouldExclude reports whether a directory should be excluded by the pattern. @@ -441,7 +465,7 @@ func dirCouldContainMatch(patParts, dirParts []string) bool { // This prevents "**/vendor/**" from excluding directories that don't contain "vendor" // in their path. func dirCouldExclude(patParts, dirParts []string) bool { - return matchSegmentsStrict(patParts, dirParts, 0, 0, false) + return matchSegmentsStrict(collapseDoubleStars(patParts), dirParts, 0, 0, false) } // matchSegments walks pattern and directory segments to determine if the pattern @@ -485,6 +509,14 @@ func matchSegments(pat, dir []string, pi, di int) bool { // evaluation. It tracks whether at least one non-"**" pattern segment was matched // against a real directory segment. If not, remaining literal segments are // considered unvalidated and the match is rejected. +// +// Truth table: +// +// Pattern | Dir | Result | Why +// * | foo | false | pattern exhausted, no remaining segments +// foo/* | foo | true | literal "foo" matched, * remains for children +// **/vendor/** | src | false | no literal matched, ** can't validate alone +// **/vendor/** | vendor | true | "vendor" literal matched, ** remains func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) bool { for pi < len(pat) && di < len(dir) { p := pat[pi] diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index d2815b6..70addd5 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1319,6 +1319,16 @@ func TestResolve_DirQuery_MutuallyExclusive(t *testing.T) { } } +func TestResolve_EmptyPaths(t *testing.T) { + _, _, err := Resolve(ResolveRequest{ + Action: ActionAll, + Timing: TimingAll, + }) + if err == nil { + t.Fatal("expected error when both FilePath and DirPath are empty") + } +} + func TestResolve_DirQuery_SelfDirectory(t *testing.T) { // Query the directory that contains the AGENTS.yaml itself. tmpDir := t.TempDir() @@ -1818,7 +1828,7 @@ context: assertContextContents(t, result.ContextEntries, []string{"vendor-scoped"}) } -// assertContextContents checks// assertContextContents checks that the matched context entries have exactly +// assertContextContents checks that the matched context entries have exactly // the expected content strings, in order. func assertContextContents(t *testing.T, got []MatchedContext, want []string) { t.Helper() From 17980200244988e82c4d33e9cc0ce3f4732e8e70 Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Tue, 24 Mar 2026 18:00:13 -0400 Subject: [PATCH 6/9] better tests and fixing bugs --- internal/core/engine.go | 14 +++- internal/core/engine_test.go | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/internal/core/engine.go b/internal/core/engine.go index 93b9fd6..f0079dc 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -377,11 +377,21 @@ func anyDirPatternExcludes(relDir string, patterns []string) bool { // dirSlashPatternMatches checks a trailing-slash pattern against a directory path. func dirSlashPatternMatches(relDir, pattern string) bool { - dirWithSlash := relDir + "/" if relDir == "" { - dirWithSlash = "./" + // Source directory (relDir="") should only match patterns that can + // match zero path segments. Using "./" as a stand-in is incorrect + // because "*" matches "." in glob semantics, causing "*/" and + // "**/*/" to falsely match the source directory. + // Only bare **/ chains (meaning "any directory") match zero segments. + trimmed := pattern + for strings.HasPrefix(trimmed, "**/") { + trimmed = trimmed[3:] + } + + return trimmed == "" } + dirWithSlash := relDir + "/" ok, err := doublestar.Match(pattern, dirWithSlash) return err == nil && ok diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index 70addd5..25a49f7 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1828,6 +1828,128 @@ context: assertContextContents(t, result.ContextEntries, []string{"vendor-scoped"}) } +func TestResolve_DirQuery_WildcardDirPatternsAtRoot(t *testing.T) { + // Trailing-slash dir patterns with wildcards must behave correctly when + // querying the source directory itself (relDir=""). The "*" glob matches + // "." in doublestar, so a naive "./" stand-in breaks patterns like "*/". + tests := []struct { + name string + yaml string + relDir string // relative to tmpDir; "" = query tmpDir itself + wantN int + }{ + // */ means "any immediate child directory" — must NOT match source + {"match */ at root", ` +context: + - content: "x" + match: ["*/"] +`, "", 0}, + {"match */ at child", ` +context: + - content: "x" + match: ["*/"] +`, "src", 1}, + {"match */ at grandchild", ` +context: + - content: "x" + match: ["*/"] +`, "src/api", 0}, + + // **/ means "any directory at any depth" — should match everything including root + {"match **/ at root", ` +context: + - content: "x" + match: ["**/"] +`, "", 1}, + {"match **/ at child", ` +context: + - content: "x" + match: ["**/"] +`, "src", 1}, + + // **/src/ should match src but not root + {"match **/src/ at root", ` +context: + - content: "x" + match: ["**/src/"] +`, "", 0}, + {"match **/src/ at src", ` +context: + - content: "x" + match: ["**/src/"] +`, "src", 1}, + + // **/*/ has the same problem as */ — the trailing * must not match "." + {"match **/*/ at root", ` +context: + - content: "x" + match: ["**/*/"] +`, "", 0}, + {"match **/*/ at child", ` +context: + - content: "x" + match: ["**/*/"] +`, "src", 1}, + + // exclude: ["*/"] must NOT exclude root + {"exclude */ at root", ` +context: + - content: "x" + exclude: ["*/"] +`, "", 1}, + {"exclude */ at child", ` +context: + - content: "x" + exclude: ["*/"] +`, "src", 0}, + + // exclude: ["**/*/"] must NOT exclude root + {"exclude **/*/ at root", ` +context: + - content: "x" + exclude: ["**/*/"] +`, "", 1}, + {"exclude **/*/ at child", ` +context: + - content: "x" + exclude: ["**/*/"] +`, "src", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmpDir, "src", "api"), 0o750); err != nil { + t.Fatal(err) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), tt.yaml) + + queryDir := tmpDir + if tt.relDir != "" { + queryDir = filepath.Join(tmpDir, tt.relDir) + } + + result, _, err := Resolve(ResolveRequest{ + DirPath: queryDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != tt.wantN { + got := make([]string, len(result.ContextEntries)) + for i, e := range result.ContextEntries { + got[i] = e.Content + } + t.Errorf("got %d entries %v, want %d", len(result.ContextEntries), got, tt.wantN) + } + }) + } +} + // assertContextContents checks that the matched context entries have exactly // the expected content strings, in order. func assertContextContents(t *testing.T, got []MatchedContext, want []string) { From b4784f984cbb31d1ac5cefaa45554e5d8194c8e9 Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Tue, 24 Mar 2026 18:38:45 -0400 Subject: [PATCH 7/9] pushing bot to use TDD --- internal/core/engine.go | 10 +- internal/core/engine_test.go | 224 ++++++++++++++++++++++------------- 2 files changed, 150 insertions(+), 84 deletions(-) diff --git a/internal/core/engine.go b/internal/core/engine.go index f0079dc..dd84c4d 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -382,7 +382,15 @@ func dirSlashPatternMatches(relDir, pattern string) bool { // match zero path segments. Using "./" as a stand-in is incorrect // because "*" matches "." in glob semantics, causing "*/" and // "**/*/" to falsely match the source directory. - // Only bare **/ chains (meaning "any directory") match zero segments. + // + // "./" is an explicit self-reference (used in docs/examples.md). + // Bare **/ chains mean "any directory" and match zero segments. + // Everything else (*/ src/ **/src/ **/*/) requires real path + // segments and must not match the source directory. + if pattern == "./" { + return true + } + trimmed := pattern for strings.HasPrefix(trimmed, "**/") { trimmed = trimmed[3:] diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index 25a49f7..43e9d43 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -518,6 +518,10 @@ func genGlob(t *rapid.T) string { return rapid.SampledFrom([]string{ "**", "**/*.go", "**/*.py", "*.txt", "src/**", "**/*.js", "docs/**", "*.md", "**/*_test.go", "vendor/**", + // Directory-targeting patterns (without trailing slash — callers add it). + // These exercise the dirSlashPatternMatches code paths. + ".", "*", "src", "src/*", "src/**", + "**/src", "**/src/**", "**/*", }).Draw(t, "glob") } @@ -1828,92 +1832,86 @@ context: assertContextContents(t, result.ContextEntries, []string{"vendor-scoped"}) } -func TestResolve_DirQuery_WildcardDirPatternsAtRoot(t *testing.T) { - // Trailing-slash dir patterns with wildcards must behave correctly when - // querying the source directory itself (relDir=""). The "*" glob matches - // "." in doublestar, so a naive "./" stand-in breaks patterns like "*/". +func TestResolve_DirQuery_DirSlashPatternsAtRoot(t *testing.T) { + // Systematic coverage of every trailing-slash pattern form when querying + // the source directory (relDir="") vs child directories. This prevents + // regressions in dirSlashPatternMatches which has tricky edge cases: + // - "*" matches "." in glob semantics (so "./" stand-in is dangerous) + // - "./" is a valid explicit self-reference (from docs/examples.md) + // - "**/" can match zero segments (so it includes self) + // + // Each row tests one pattern in either match or exclude position. + // "field" is "match" or "exclude". For match: wantN=1 means matched, + // wantN=0 means not matched. For exclude: wantN=0 means excluded, + // wantN=1 means not excluded. tests := []struct { name string - yaml string - relDir string // relative to tmpDir; "" = query tmpDir itself + field string // "match" or "exclude" + pat string // trailing-slash pattern + relDir string // "" = source dir, else relative path wantN int }{ - // */ means "any immediate child directory" — must NOT match source - {"match */ at root", ` -context: - - content: "x" - match: ["*/"] -`, "", 0}, - {"match */ at child", ` -context: - - content: "x" - match: ["*/"] -`, "src", 1}, - {"match */ at grandchild", ` -context: - - content: "x" - match: ["*/"] -`, "src/api", 0}, - - // **/ means "any directory at any depth" — should match everything including root - {"match **/ at root", ` -context: - - content: "x" - match: ["**/"] -`, "", 1}, - {"match **/ at child", ` -context: - - content: "x" - match: ["**/"] -`, "src", 1}, - - // **/src/ should match src but not root - {"match **/src/ at root", ` -context: - - content: "x" - match: ["**/src/"] -`, "", 0}, - {"match **/src/ at src", ` -context: - - content: "x" - match: ["**/src/"] -`, "src", 1}, - - // **/*/ has the same problem as */ — the trailing * must not match "." - {"match **/*/ at root", ` -context: - - content: "x" - match: ["**/*/"] -`, "", 0}, - {"match **/*/ at child", ` -context: - - content: "x" - match: ["**/*/"] -`, "src", 1}, - - // exclude: ["*/"] must NOT exclude root - {"exclude */ at root", ` -context: - - content: "x" - exclude: ["*/"] -`, "", 1}, - {"exclude */ at child", ` -context: - - content: "x" - exclude: ["*/"] -`, "src", 0}, - - // exclude: ["**/*/"] must NOT exclude root - {"exclude **/*/ at root", ` -context: - - content: "x" - exclude: ["**/*/"] -`, "", 1}, - {"exclude **/*/ at child", ` -context: - - content: "x" - exclude: ["**/*/"] -`, "src", 0}, + // ./ — explicit self-reference (documented in examples.md) + {"match ./ at root", "match", "./", "", 1}, + {"match ./ at child", "match", "./", "src", 0}, + {"exclude ./ at root", "exclude", "./", "", 0}, + {"exclude ./ at child", "exclude", "./", "src", 1}, + + // */ — any immediate child directory + {"match */ at root", "match", "*/", "", 0}, + {"match */ at child", "match", "*/", "src", 1}, + {"match */ at grandchild", "match", "*/", "src/api", 0}, + {"exclude */ at root", "exclude", "*/", "", 1}, + {"exclude */ at child", "exclude", "*/", "src", 0}, + + // **/ — any directory at any depth (includes self) + {"match **/ at root", "match", "**/", "", 1}, + {"match **/ at child", "match", "**/", "src", 1}, + {"exclude **/ at root", "exclude", "**/", "", 0}, + {"exclude **/ at child", "exclude", "**/", "src", 0}, + + // src/ — named literal child + {"match src/ at root", "match", "src/", "", 0}, + {"match src/ at src", "match", "src/", "src", 1}, + {"match src/ at api", "match", "src/", "src/api", 0}, + {"exclude src/ at root", "exclude", "src/", "", 1}, + {"exclude src/ at src", "exclude", "src/", "src", 0}, + + // **/src/ — named at any depth + {"match **/src/ at root", "match", "**/src/", "", 0}, + {"match **/src/ at src", "match", "**/src/", "src", 1}, + {"exclude **/src/ at root", "exclude", "**/src/", "", 1}, + {"exclude **/src/ at src", "exclude", "**/src/", "src", 0}, + + // **/*/ — any named dir at any depth (must not match root) + {"match **/*/ at root", "match", "**/*/", "", 0}, + {"match **/*/ at child", "match", "**/*/", "src", 1}, + {"exclude **/*/ at root", "exclude", "**/*/", "", 1}, + {"exclude **/*/ at child", "exclude", "**/*/", "src", 0}, + + // src/*/ — child of a named dir + {"match src/*/ at root", "match", "src/*/", "", 0}, + {"match src/*/ at src", "match", "src/*/", "src", 0}, + {"match src/*/ at src/api", "match", "src/*/", "src/api", 1}, + + // src/**/ — anything under src + {"match src/**/ at root", "match", "src/**/", "", 0}, + {"match src/**/ at src", "match", "src/**/", "src", 1}, + {"match src/**/ at src/api", "match", "src/**/", "src/api", 1}, + + // **/**/ — consecutive double-star chains (equivalent to **/) + {"match **/**/ at root", "match", "**/**/", "", 1}, + {"match **/**/ at child", "match", "**/**/", "src", 1}, + + // **/src/**/ — double-star + literal + double-star + {"match **/src/**/ at root", "match", "**/src/**/", "", 0}, + {"match **/src/**/ at src", "match", "**/src/**/", "src", 1}, + {"match **/src/**/ at src/api", "match", "**/src/**/", "src/api", 1}, + {"match **/src/**/ at other", "match", "**/src/**/", "other", 0}, + + // character class patterns — must not match root + {"match [st]rc/ at root", "match", "[st]rc/", "", 0}, + {"match [st]rc/ at src", "match", "[st]rc/", "src", 1}, } for _, tt := range tests { @@ -1923,7 +1921,14 @@ context: t.Fatal(err) } - writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), tt.yaml) + var yaml string + if tt.field == "exclude" { + yaml = fmt.Sprintf("context:\n - content: \"x\"\n exclude: [\"%s\"]", tt.pat) + } else { + yaml = fmt.Sprintf("context:\n - content: \"x\"\n match: [\"%s\"]", tt.pat) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), yaml) queryDir := tmpDir if tt.relDir != "" { @@ -1950,6 +1955,59 @@ context: } } +func TestResolve_DirQuery_MixedPatternTypes(t *testing.T) { + // Verify that dir-slash match patterns and file-glob exclude patterns + // (or vice versa) interact correctly. + tmpDir := t.TempDir() + testsDir := filepath.Join(tmpDir, "tests") + srcDir := filepath.Join(tmpDir, "src") + for _, d := range []string{testsDir, srcDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "dir match with file-glob exclude" + match: ["tests/"] + exclude: ["tests/**"] + - content: "file-glob match with dir exclude" + match: ["src/**"] + exclude: ["src/"] +`) + + // tests/ matches the dir pattern, but the file-glob exclude "tests/**" + // goes through fileGlobExcludesDir — it should exclude since tests/ is + // within its scope. + result, _, err := Resolve(ResolveRequest{ + DirPath: testsDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != 0 { + t.Errorf("tests/: expected 0 entries (excluded by file-glob), got %d", len(result.ContextEntries)) + } + + // src/ matches the file-glob "src/**", but is excluded by the dir pattern "src/". + result, _, err = Resolve(ResolveRequest{ + DirPath: srcDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != 0 { + t.Errorf("src/: expected 0 entries (excluded by dir pattern), got %d", len(result.ContextEntries)) + } +} + // assertContextContents checks that the matched context entries have exactly // the expected content strings, in order. func assertContextContents(t *testing.T, got []MatchedContext, want []string) { From 7ef614728805145022922ec482eee208d6a64c71 Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Tue, 24 Mar 2026 18:53:32 -0400 Subject: [PATCH 8/9] lol, this time for sure --- internal/core/engine.go | 28 ++++++++--------- internal/core/engine_test.go | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/internal/core/engine.go b/internal/core/engine.go index dd84c4d..f198f14 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -384,14 +384,13 @@ func dirSlashPatternMatches(relDir, pattern string) bool { // "**/*/" to falsely match the source directory. // // "./" is an explicit self-reference (used in docs/examples.md). + // "./**/" means "self and all subdirs". Strip "./" first so the + // **/ loop handles the rest. // Bare **/ chains mean "any directory" and match zero segments. // Everything else (*/ src/ **/src/ **/*/) requires real path // segments and must not match the source directory. - if pattern == "./" { - return true - } + trimmed := strings.TrimPrefix(pattern, "./") - trimmed := pattern for strings.HasPrefix(trimmed, "**/") { trimmed = trimmed[3:] } @@ -399,8 +398,10 @@ func dirSlashPatternMatches(relDir, pattern string) bool { return trimmed == "" } + // Strip "./" prefix — patterns are already relative to sourceDir, + // so "./" is redundant and doublestar treats "." as a literal segment. dirWithSlash := relDir + "/" - ok, err := doublestar.Match(pattern, dirWithSlash) + ok, err := doublestar.Match(strings.TrimPrefix(pattern, "./"), dirWithSlash) return err == nil && ok } @@ -570,17 +571,12 @@ func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) boo return false } - if literalMatched { - return true - } - - // No literals validated. Only match for single filename segment - // or remaining starting with **. - if len(remaining) == 1 { - return true - } - - return remaining[0] == "**" + // At least one non-** segment must have been validated against a + // real directory segment. Without that, we can't confirm this + // directory is in scope. For example, **/*.py has remaining ["*.py"] + // after ** consumes all dirs, but no directory was validated — the + // pattern targets files, not directories. + return literalMatched } return false diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index 43e9d43..5d6bccf 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1912,6 +1912,10 @@ func TestResolve_DirQuery_DirSlashPatternsAtRoot(t *testing.T) { // character class patterns — must not match root {"match [st]rc/ at root", "match", "[st]rc/", "", 0}, {"match [st]rc/ at src", "match", "[st]rc/", "src", 1}, + + // ./**/ — self plus all subdirectories + {"match ./**/ at root", "match", "./**/", "", 1}, + {"match ./**/ at child", "match", "./**/", "src", 1}, } for _, tt := range tests { @@ -1955,6 +1959,60 @@ func TestResolve_DirQuery_DirSlashPatternsAtRoot(t *testing.T) { } } +func TestResolve_DirQuery_ExcludeFileGlobFilename(t *testing.T) { + // exclude: ["**/*.py"] targets Python files, not directories. + // It must NOT exclude directories in directory queries. + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + apiDir := filepath.Join(tmpDir, "src", "api") + for _, d := range []string{apiDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "not python" + exclude: ["**/*.py"] + - content: "not tests" + exclude: ["**/*_test.go"] + - content: "not generated" + exclude: ["**/generated/*.js"] +`) + + tests := []struct { + name string + dir string + wantN int + }{ + {"root not excluded", tmpDir, 3}, + {"src/ not excluded", srcDir, 3}, + {"src/api/ not excluded", apiDir, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := Resolve(ResolveRequest{ + DirPath: tt.dir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(result.ContextEntries) != tt.wantN { + got := make([]string, len(result.ContextEntries)) + for i, e := range result.ContextEntries { + got[i] = e.Content + } + t.Errorf("got %d entries %v, want %d", len(result.ContextEntries), got, tt.wantN) + } + }) + } +} + func TestResolve_DirQuery_MixedPatternTypes(t *testing.T) { // Verify that dir-slash match patterns and file-glob exclude patterns // (or vice versa) interact correctly. From c10dca5b7a3343fbfda0e867054333f3327dc0d8 Mon Sep 17 00:00:00 2001 From: Greg Clarke Date: Tue, 24 Mar 2026 19:14:08 -0400 Subject: [PATCH 9/9] at least there are no critical issues now --- cmd/sctx/main.go | 2 +- docs/examples.md | 2 +- internal/core/engine.go | 82 ++++++++++-------------------------- internal/core/engine_test.go | 65 +++++++++++++++++++++++++--- 4 files changed, 84 insertions(+), 67 deletions(-) diff --git a/cmd/sctx/main.go b/cmd/sctx/main.go index 30875a7..6f3f036 100644 --- a/cmd/sctx/main.go +++ b/cmd/sctx/main.go @@ -349,7 +349,7 @@ decisions: // It checks if the path exists as a directory on disk, or ends with a path separator. // When the path doesn't exist, it falls back to the trailing slash convention. func isDir(absPath, originalPath string) bool { - info, err := os.Stat(absPath) //nolint:gosec // absPath is derived from filepath.Abs, not user-controlled taint + info, err := os.Stat(absPath) //nolint:gosec // os.Stat is read-only, safe on user-provided paths if err == nil { return info.IsDir() } diff --git a/docs/examples.md b/docs/examples.md index 603b7d2..c1aefe5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -310,7 +310,7 @@ decisions: - decision: "Webhook handlers in this package, not in the API gateway" rationale: "Payment webhooks need access to payment domain logic for validation" - match: ["./"] # scoped to this directory only, not inherited by subdirectories + match: ["*"] # files directly in this directory, not inherited by subdirectories ``` When an agent edits `packages/payments/src/checkout.ts`, it gets the shared monorepo conventions *and* the payments-specific context. The payments context appears last, giving it stronger influence. diff --git a/internal/core/engine.go b/internal/core/engine.go index f198f14..8fe92b3 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -61,10 +61,10 @@ func resolveFile(req ResolveRequest, root string) (*ResolveResult, []string, err result := &ResolveResult{} for _, cf := range files { - matchedCtx := filterContext(cf, absPath, req.Action, req.Timing) + matchedCtx := filterContext(cf, absPath, req.Action, req.Timing, matchesFileGlobs) result.ContextEntries = append(result.ContextEntries, matchedCtx...) - matchedDec := filterDecisions(cf, absPath) + matchedDec := filterDecisions(cf, absPath, matchesFileGlobs) result.DecisionEntries = append(result.DecisionEntries, matchedDec...) } @@ -84,10 +84,10 @@ func resolveDir(req ResolveRequest, root string) (*ResolveResult, []string, erro result := &ResolveResult{} for _, cf := range files { - matchedCtx := filterContextDir(cf, absDir, req.Action, req.Timing) + matchedCtx := filterContext(cf, absDir, req.Action, req.Timing, matchesDirGlobs) result.ContextEntries = append(result.ContextEntries, matchedCtx...) - matchedDec := filterDecisionsDir(cf, absDir) + matchedDec := filterDecisions(cf, absDir, matchesDirGlobs) result.DecisionEntries = append(result.DecisionEntries, matchedDec...) } @@ -171,38 +171,15 @@ func applyDefaults(cf *ContextFile) { } } -// filterContext returns context entries from cf that match the given file, action, and timing. -func filterContext(cf ContextFile, absPath string, action Action, timing Timing) []MatchedContext { - var matched []MatchedContext - - for _, entry := range cf.Context { - if !matchesFileGlobs(cf.sourceDir, absPath, entry.Match, entry.Exclude) { - continue - } - - if !matchesAction(entry.On, action) { - continue - } - - if timing != TimingAll && Timing(entry.When) != TimingAll && Timing(entry.When) != timing { - continue - } - - matched = append(matched, MatchedContext{ - Content: entry.Content, - SourceDir: cf.sourceDir, - }) - } - - return matched -} +// globMatcher checks whether a path matches the given match/exclude patterns. +type globMatcher func(sourceDir, path string, match, exclude []string) bool -// filterContextDir returns context entries from cf that match the given directory, action, and timing. -func filterContextDir(cf ContextFile, absDir string, action Action, timing Timing) []MatchedContext { +// filterContext returns context entries from cf that match the given path, action, and timing. +func filterContext(cf ContextFile, absPath string, action Action, timing Timing, matcher globMatcher) []MatchedContext { var matched []MatchedContext for _, entry := range cf.Context { - if !matchesDirGlobs(cf.sourceDir, absDir, entry.Match, entry.Exclude) { + if !matcher(cf.sourceDir, absPath, entry.Match, entry.Exclude) { continue } @@ -223,27 +200,12 @@ func filterContextDir(cf ContextFile, absDir string, action Action, timing Timin return matched } -// filterDecisions returns decision entries from cf that match the given file. -func filterDecisions(cf ContextFile, absPath string) []DecisionEntry { - var matched []DecisionEntry - - for _, entry := range cf.Decisions { - if !matchesFileGlobs(cf.sourceDir, absPath, entry.Match, nil) { - continue - } - - matched = append(matched, entry) - } - - return matched -} - -// filterDecisionsDir returns decision entries from cf that match the given directory. -func filterDecisionsDir(cf ContextFile, absDir string) []DecisionEntry { +// filterDecisions returns decision entries from cf that match the given path. +func filterDecisions(cf ContextFile, absPath string, matcher globMatcher) []DecisionEntry { var matched []DecisionEntry for _, entry := range cf.Decisions { - if !matchesDirGlobs(cf.sourceDir, absDir, entry.Match, nil) { + if !matcher(cf.sourceDir, absPath, entry.Match, nil) { continue } @@ -526,17 +488,19 @@ func matchSegments(pat, dir []string, pi, di int) bool { // matchSegmentsStrict is the strict version of matchSegments, used for exclude // evaluation. It tracks whether at least one non-"**" pattern segment was matched -// against a real directory segment. If not, remaining literal segments are -// considered unvalidated and the match is rejected. +// against a real directory segment (segmentValidated). If not, the directory +// is not confirmed to be in scope and the match is rejected. // // Truth table: // // Pattern | Dir | Result | Why // * | foo | false | pattern exhausted, no remaining segments -// foo/* | foo | true | literal "foo" matched, * remains for children -// **/vendor/** | src | false | no literal matched, ** can't validate alone -// **/vendor/** | vendor | true | "vendor" literal matched, ** remains -func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) bool { +// foo/* | foo | true | "foo" validated, * remains for children +// **/vendor/** | src | false | no segment validated, ** can't confirm alone +// **/vendor/** | vendor | true | "vendor" validated, ** remains +// **/*.py | src | false | ** consumed src but *.py is just a filename +// */*.py | src | true | * validated against src, *.py remains +func matchSegmentsStrict(pat, dir []string, pi, di int, segmentValidated bool) bool { for pi < len(pat) && di < len(dir) { p := pat[pi] @@ -546,7 +510,7 @@ func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) boo } for skip := 0; skip <= len(dir)-di; skip++ { - if matchSegmentsStrict(pat, dir, pi+1, di+skip, literalMatched) { + if matchSegmentsStrict(pat, dir, pi+1, di+skip, segmentValidated) { return true } } @@ -559,7 +523,7 @@ func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) boo return false } - literalMatched = true + segmentValidated = true pi++ di++ } @@ -576,7 +540,7 @@ func matchSegmentsStrict(pat, dir []string, pi, di int, literalMatched bool) boo // directory is in scope. For example, **/*.py has remaining ["*.py"] // after ** consumes all dirs, but no directory was validated — the // pattern targets files, not directories. - return literalMatched + return segmentValidated } return false diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index 5d6bccf..fa4cef1 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -1546,16 +1546,35 @@ func TestResolve_DirQuery_NeverPanics(t *testing.T) { } } - action := genAction(rt) - timing := genTiming(rt) + // Add a catch-all entry at the root that must always appear. + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "catch-all" + on: all + when: all +`) - // Must not panic. - _, _, _ = Resolve(ResolveRequest{ + // Must not panic, and the catch-all must always be present. + result, _, err := Resolve(ResolveRequest{ DirPath: dir, - Action: action, - Timing: timing, + Action: ActionAll, + Timing: TimingAll, Root: tmpDir, }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + found := false + for _, e := range result.ContextEntries { + if e.Content == "catch-all" { + found = true + break + } + } + if !found { + t.Fatalf("catch-all entry (match: [**], no exclude) missing for dir %s", dir) + } }) } @@ -2013,6 +2032,40 @@ context: } } +func TestResolve_DirQuery_ExcludeSingleStarVsDoubleStar(t *testing.T) { + // * validates against a directory segment (it matches a specific name), + // while ** just bridges without validating. So exclude: ["*/*.py"] excludes + // top-level directories (the * confirmed the dir is in scope), but + // exclude: ["**/*.py"] does not (no directory was validated). + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + if err := os.MkdirAll(srcDir, 0o750); err != nil { + t.Fatal(err) + } + + writeTestFile(t, filepath.Join(tmpDir, "AGENTS.yaml"), ` +context: + - content: "excluded by star-slash" + exclude: ["*/*.py"] + - content: "not excluded by doublestar" + exclude: ["**/*.py"] +`) + + result, _, err := Resolve(ResolveRequest{ + DirPath: srcDir, + Action: ActionAll, + Timing: TimingAll, + Root: tmpDir, + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + // */*.py excludes src/ (single * validated against "src"). + // **/*.py does NOT exclude src/ (** consumed "src" without validation). + assertContextContents(t, result.ContextEntries, []string{"not excluded by doublestar"}) +} + func TestResolve_DirQuery_MixedPatternTypes(t *testing.T) { // Verify that dir-slash match patterns and file-glob exclude patterns // (or vice versa) interact correctly.