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/cmd/sctx/main.go b/cmd/sctx/main.go index 38d4df9..6f3f036 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,19 @@ 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. +// 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 // os.Stat is read-only, safe on user-provided paths + 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)) +} + func cmdClaude(args []string) error { if len(args) < 1 { return errClaudeSubcommand diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ad003cd..069c016 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 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 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..bf4b3d2 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,33 @@ 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/"] +``` + +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 +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..c1aefe5 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: ["*"] # 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/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. diff --git a/internal/core/engine.go b/internal/core/engine.go index 95a39e7..8fe92b3 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -1,6 +1,7 @@ package core import ( + "errors" "fmt" "os" "path/filepath" @@ -10,21 +11,31 @@ import ( "gopkg.in/yaml.v3" ) +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{ "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 + } + + if req.FilePath == "" && req.DirPath == "" { + return nil, nil, errPathRequired } root := req.Root + var err error if root == "" { root, err = os.Getwd() if err != nil { @@ -32,15 +43,51 @@ 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{} 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, matchesFileGlobs) + result.DecisionEntries = append(result.DecisionEntries, matchedDec...) + } + + 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 := filterContext(cf, absDir, req.Action, req.Timing, matchesDirGlobs) result.ContextEntries = append(result.ContextEntries, matchedCtx...) - matchedDec := filterDecisions(cf, absPath) + matchedDec := filterDecisions(cf, absDir, matchesDirGlobs) result.DecisionEntries = append(result.DecisionEntries, matchedDec...) } @@ -124,12 +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 { +// globMatcher checks whether a path matches the given match/exclude patterns. +type globMatcher func(sourceDir, path string, match, exclude []string) bool + +// 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 !matchesGlobs(cf.sourceDir, absPath, entry.Match, entry.Exclude) { + if !matcher(cf.sourceDir, absPath, entry.Match, entry.Exclude) { continue } @@ -150,12 +200,12 @@ func filterContext(cf ContextFile, absPath string, action Action, timing Timing) return matched } -// filterDecisions returns decision entries from cf that match the given file. -func filterDecisions(cf ContextFile, absPath 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 !matchesGlobs(cf.sourceDir, absPath, entry.Match, nil) { + if !matcher(cf.sourceDir, absPath, entry.Match, nil) { continue } @@ -165,18 +215,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 +238,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 +258,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 +275,277 @@ 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. +// +// 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 { + return false + } + + relDir = filepath.ToSlash(relDir) + + if strings.HasPrefix(relDir, "..") { + return false + } + + if relDir == "." { + relDir = "" + } + + if !anyDirPatternMatches(relDir, match) { + return false + } + + return !anyDirPatternExcludes(relDir, exclude) +} + +// 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 isDirPattern(pattern) { + if dirSlashPatternMatches(relDir, pattern) { + return true + } + } else if fileGlobMatchesDir(pattern, relDir) { + return true + } + } + + return false +} + +// 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 false +} + +// dirSlashPatternMatches checks a trailing-slash pattern against a directory path. +func dirSlashPatternMatches(relDir, pattern string) bool { + if relDir == "" { + // 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. + // + // "./" 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. + trimmed := strings.TrimPrefix(pattern, "./") + + for strings.HasPrefix(trimmed, "**/") { + trimmed = trimmed[3:] + } + + 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(strings.TrimPrefix(pattern, "./"), dirWithSlash) + + return err == nil && ok +} + +// fileGlobMatchesDir reports whether a file-glob pattern could match files inside relDir. +// 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 { + if pattern == "**" || pattern == "**/*" { + return true + } + + // Any pattern could match files in the sourceDir itself. + if relDir == "" { + return true + } + + // 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) +} + +// 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(collapseDoubleStars(patParts), dirParts, 0, 0) +} + +// 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(collapseDoubleStars(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 (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 | "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] + + if p == "**" { + if pi == len(pat)-1 { + return true + } + + for skip := 0; skip <= len(dir)-di; skip++ { + if matchSegmentsStrict(pat, dir, pi+1, di+skip, segmentValidated) { + return true + } + } + + return false + } + + ok, err := doublestar.Match(p, dir[di]) + if err != nil || !ok { + return false + } + + segmentValidated = true + pi++ + di++ + } + + if di == len(dir) { + remaining := pat[pi:] + + if len(remaining) == 0 { + return false + } + + // 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 segmentValidated + } + + 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..fa4cef1 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") } @@ -1054,6 +1058,1067 @@ 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_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() + 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/"] +`) + + tests := []struct { + name string + dir string + wantN int + }{ + {"src/ not excluded", srcDir, 1}, + {"vendor/ excluded", vendorDir, 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_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) + } + } + + // 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, and the catch-all must always be present. + result, _, err := Resolve(ResolveRequest{ + DirPath: dir, + 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) + } + }) +} + +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, testsDir, otherDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + 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 + }{ + {"src/ not excluded", srcDir, 1}, + {"vendor/ excluded", vendorDir, 0}, + {"vendor/deep excluded", vendorDeepDir, 0}, + {"src/vendor/ excluded", srcVendorDir, 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_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_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} { + 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/ matches (could contain api/ subdir)", modelsDir, 1}, + {"src/ matches", srcDir, 1}, + } + + 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_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"}) +} + +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 + field string // "match" or "exclude" + pat string // trailing-slash pattern + relDir string // "" = source dir, else relative path + wantN int + }{ + // ./ — 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}, + + // ./**/ — self plus all subdirectories + {"match ./**/ at root", "match", "./**/", "", 1}, + {"match ./**/ at child", "match", "./**/", "src", 1}, + } + + 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) + } + + 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 != "" { + 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) + } + }) + } +} + +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_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. + 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) { 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.