feat: add directory-scoped matching with trailing-slash patterns and directory queries#119
feat: add directory-scoped matching with trailing-slash patterns and directory queries#119
Conversation
Review: directory-scoped matchingNice feature — the trailing-slash convention is a good call and the PR description is genuinely excellent. But I found two bugs in the segment-walking logic that need fixing before merge. Critical
Pattern I wrote a quick test to confirm — Fix: when 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
}
This is a pretty common pattern people will write. I think the cleanest fix is to drop the WarningsMissing test coverage for both bugs above —
Minimum additions:
Notes
All the other sentinel errors in the project use OverallThe design is solid and the PR description is one of the better ones I've read — the "tricky part" section explaining the segment-walking approach and the Developer metricsTotal duration: 6m 26s
|
Review: directory-scoped matchingThis PR adds directory queries to the resolution engine via trailing-slash patterns and a new Critical
When I wrote a test to confirm: // exclude: ["vendor/**"] with DirPath = tmpDir (the root)
// Expected: 1 entry (root should not be excluded)
// Actual: 0 entries (root is incorrectly excluded)Fix options:
Either way, add a test that queries Warnings
The Trace: pattern Same issue hits The simplest correct fix: when Missing test coverage for
Missing test coverage for ancestor directory matching —
NotesFilter function duplication —
The exclude-at-root bug is a blocker — it'll bite anyone with Developer metricsTotal duration: 5m 29s
|
|
Adds directory-scoped matching to the resolution engine — trailing-slash patterns like CriticalEmpty
|
| Stage | Model | Duration | Turns | Tool calls | Tokens (in/out) | Cache read | Cache creation |
|---|---|---|---|---|---|---|---|
| analyze | claude-opus-4-6 | 1m 20s | 18 | 14 | 261,695 / 2,713 | 220,661 | 41,023 |
| review_findings | claude-opus-4-6 | 1m 36s | 12 | 8 | 384,257 / 3,150 | 333,349 | 50,896 |
| draft | claude-opus-4-6 | 0m 35s | 1 | 1 | 124,811 / 1,128 | 70,469 | 54,339 |
|
Adds directory-scoped matching to the resolution engine via a trailing-slash convention ( Warnings
|
| Stage | Model | Duration | Turns | Tool calls | Tokens (in/out) | Cache read | Cache creation |
|---|---|---|---|---|---|---|---|
| analyze | claude-opus-4-6 | 2m 9s | 33 | 25 | 590,806 / 3,513 | 526,277 | 64,508 |
| review_findings | claude-opus-4-6 | 2m 53s | 5 | 4 | 322,188 / 8,350 | 247,089 | 75,090 |
| draft | claude-opus-4-6 | 0m 25s | 1 | 1 | 172,350 / 1,081 | 94,262 | 78,085 |
Review: directory-scoped matching with trailing-slash patternsAdds directory queries to the resolution engine — trailing-slash patterns like Critical
This matters because match: ["./"] # scoped to this directory only, not inherited by subdirectoriesAnyone copying that example gets silent failure. Fix: handle Warnings
The segment walker would actually produce the same result for most of these patterns (since
There's no test where
This variable is set to true when any non- Notes
The Claude and Pi adapters only use
Trailing-slash patterns pass validation (they're syntactically valid globs), which is fine. Once the The design here is solid — the trailing-slash convention is familiar, the generous/strict split for match vs exclude is a good call, and the test coverage is thorough (property-based fuzz test included). The Developer metricsTotal duration: 6m 54s
|
Review: directory-scoped matchingNice feature. The trailing-slash convention is a good call — familiar from One real bug to fix before merging, plus a few smaller things. Critical
The single-remaining-segment exception returns The root cause is here: // No literals validated. Only match for single filename segment
// or remaining starting with **.
if len(remaining) == 1 {
return true // <-- should be: return literalMatched
}
return remaining[0] == "**" // <-- should be: return literalMatched || remaining[0] == "**"When This is a common pattern in real AGENTS.yaml files — Warnings
The Minor edge case since users would probably write
The existing exclude tests use
If Stat fails for permission denied or symlink loop (not just "not exists"), the function falls through to trailing-slash detection. A directory without a trailing slash gets misclassified as a file, producing silently wrong results. Worth at minimum a comment; ideally check
All patterns starting with Notes
It tracks whether any non-
The critical exclude bug is a blocker — it'll silently drop context for common patterns. The rest is cleanup. Overall the design is solid and the test suite is unusually good. Developer metricsTotal duration: 6m 42s
|
Review: directory-scoped matching with trailing-slash patternsAdds directory queries to the resolution engine via a trailing-slash convention ( Nice piece of work — the design is well-motivated, the trailing-slash convention is the right call, and the test coverage is thorough. A few things caught my eye: Warnings
The new webhook-handlers decision uses The paragraph right below still says "When an agent edits Fix: either add a note that
One way to clean this up: have
That asymmetry between
Notes
The
The Claude and Pi adapters still only set
It tracks any non- Overall this is solid. The core algorithm is well-reasoned, the test matrix for Developer metricsTotal duration: 4m 58s
|
|
Adds directory-scoped matching to the resolution engine — trailing-slash patterns like Really well-structured PR. The match/exclude asymmetry is a smart design call and the test coverage on the core engine is thorough. A few things worth looking at: WarningsValidator doesn't know about directory patterns —
Suggestion: strip the trailing Recursive backtracking in segment matchers —
Suggestion: either add a recursion budget (bail after ~10k calls with a safe default), or reject patterns with more than 2-3 NotesAdapters don't support directory queries yet — Both Claude and Pi adapters only set Worth adding a short comment near the No CLI-level tests for directory routing — The Match/exclude asymmetry could use a concrete example — The docs explain the generous-vs-strict distinction well in prose, but a side-by-side example would make it click faster. Something like: No blockers here. The core algorithm and tests are solid — the warnings are about hardening edge cases rather than correctness issues in normal use. Developer metricsTotal duration: 5m 24s
|
The problem
All matching in sctx is file-oriented.
Resolvetakes a file path, matches it against glob patterns, and returns results. There's no way to ask "what decisions apply tosrc/api/?" without naming a specific file inside it.This matters because decisions and context are often about directories, not individual files. "We chose REST over GraphQL" applies to
src/api/as a whole. An agent planning work there needs that context before it opens any file. But today the only way to surface it is to query for a specific file likesrc/api/handler.pyand hope the globs work out.There's a second gap: you can't scope an entry to a directory without it leaking into subdirectories.
match: ["tests/**"]matches everything undertests/recursively. If a decision only applies to thetests/directory itself (say, "fixtures live in conftest.py at this level, don't duplicate them in subdirectories"), there's no way to express that.Both gaps also block future features like skills, which are inherently directory-scoped.
The approach: trailing-slash convention
A
matchpattern ending with/targets a directory.match: ["tests/"]applies to thetests/directory but not totests/unit/and not to files insidetests/. This follows the same convention as.gitignoreandrsync, so it's already familiar.Full glob syntax works in directory patterns.
match: ["foo/**/tests/"]matches anytests/directory anywhere underfoo/.match: ["**/api/"]matches any directory namedapi/at any depth.For the resolution engine,
ResolveRequestgets aDirPathfield (mutually exclusive withFilePath). When set, discovery starts from the directory itself instead of its parent, and the matching logic handles both trailing-slash patterns and file-glob patterns against the directory.The CLI detects directory arguments automatically.
sctx decisions src/api/does a directory query.sctx decisions src/api/handler.pydoes a file query. No new flags needed.The tricky part: file-glob patterns in directory queries
When you query a directory, trailing-slash patterns are straightforward: does the directory match or not. But file-glob patterns like
**/*.pyorsrc/api/handlers/*.pyneed a different question: could this pattern produce matches inside the queried directory?The first attempt used synthetic file paths. Append
/_and/_/_to the directory and test the pattern against those. This works for wildcards (**/*.pymatchessrc/_) but falls apart for fixed path components.src/api/handlers/*.pycan't matchsrc/_because_doesn't equalapi.The approach that works is segment-by-segment walking. Split the pattern and directory path by
/and walk them together, handling*,**, and literal segments. If the directory is consumed before the pattern, remaining pattern segments could match files inside. If the pattern is consumed before the directory, the pattern doesn't reach deep enough. If**appears, it can bridge any gap.One subtle case:
foo/*with directoryfoo/bar. The walk consumes bothfoo->fooand*->bar, exhausting the entire pattern. Since there's nothing left to match files insidefoo/bar/, the pattern doesn't match that directory. Butfoo/*does matchfoo/because after consumingfoo->foo, the*segment remains to match files directly infoo/.Alternatives considered
Synthetic file paths only. Append fake filenames to the directory and test patterns against them. Simple to implement but fundamentally broken for patterns with fixed path components deeper than a couple levels. We'd need to generate synthetic paths matching every possible directory structure the pattern could traverse, which is unbounded.
Separate
dir_matchfield. A dedicated field for directory patterns, keepingmatchfor files only. More explicit, but doubles the matching surface in every entry type. The trailing-slash convention keeps one field and is already well-understood.Convention only (no trailing slash, no directory queries). Put decisions in the directory's own AGENTS.yaml and let file queries pick them up. Works for simple cases but can't express "this applies to
foo/but notfoo/bar/." You'd need an AGENTS.yaml in every subdirectory just to exclude things, which inverts the problem.Regex instead of globs. More expressive but a worse developer experience. Nobody wants to write
^src/api/handlers/[^/]*\.py$whensrc/api/handlers/*.pydoes the same thing.