Ferralk / Architecture
ADR-0020: ** is recursive only as a whole path component
- Status: Accepted
- Date: 2026-09-24
Context
With recursive_double_star enabled, Ferralk made every ** run recursive,
wherever it stood, and let a **/ prefix hand over in the middle of a
component. So **/x matched sx, a/**/b matched a/xb, and a walker
exclude **/node_modules/** pruned my_node_modules. The typical JavaScript
exclude list therefore silently dropped unrelated directories whose names
merely ended in the literal (issue #419).
gitignore, Bash globstar, globset, and Oxc fast-glob all read ** as
special only when it is a whole path component. The corpus recorded the
fast-glob disagreement as fastglob-034, marked as an oracle defect.
zlob 1.6.3 does not have one reading to be faithful to. Its surfaces disagree with one another, and its tests and docs describe the whole-component reading that its filesystem glob does not implement:
matchPaths(the surface the corpus oracle replays). It splits a pattern on/once it contains**(src/compiled_pattern.zig:51). Only a segment that is exactly**is recursive (:258), so**/xalready refusessxthere. Every other segment is matched on its own, inside one component:a**/yrefusesa/x/yand**.tsrefusessrc/a.ts. A pattern without a**segment is matched against the whole path, where*crosses separators (:317-321). So*.tsacceptssrc/a.tswhile**.tsdoes not.- The filesystem glob looks for the first
**substring anywhere (src/zlob.zig:825). It then walksa**/basa/**/b, and can drop the literal next to the run. Partial**crosses separators there. A relative pattern and its absolute spelling can select different entries. - The gitignore module treats a whole segment of two or more stars as
recursive (
src/path_matcher.zig:32-34), which is Git's reading. - Docs and tests. The Rust binding documents
**as "Matches zero or more path components" (rust/src/lib.rs:139). The only zlob tests of a partial**are gitignore tests, and they assert the whole-component rule: "A segment that merely contains stars is NOT a doublestar" (test/test_gitignore.zig:239-245, with***behaving as**at:221-229). NomatchPathsor filesystem-glob test covers a partial**or a candidate likesx. The CLI help (src/main.zig:73-74) describes**as any sequence including/, which only the filesystem glob comes close to.
Decision
Maintainer decision of 2026-09-24: parity with gitignore, fast-glob,
globset, and Bash globstar outweighs zlob parity. Before 1.0, ** becomes
recursive only as a whole path component. The rule is one classification that
the tokenizer and the extglob interpreter share:
- A star run is whole-component when it is bounded on each side by an
unescaped
/or by an end of the pattern. Examples:**,**/x,x/**,x/**/y. An escaped\/is a literal byte, not a pattern separator, so it bounds nothing. - Braces expand first, so each alternative is judged as the whole pattern it
expands to.
{**,x}/yhas a recursive**/y;a{**,x}/yhas an ordinarya**/y. - An extglob alternative stands where its group stands. Its start is a
component boundary only when the group operator is. Its end is one only
when the closing parenthesis is.
@(**)/yis recursive;x@(**)/yand@(**)yare not. - A whole-component
**/consumes zero or more whole components, each with its separator. It may hand over only at a component start, so**/xmatchesxanda/xbut notsx, and a trailinga/**/needs the final separator. A whole-component**at the end consumes the rest of the path, andx/**still acceptsx. - A whole-component run of three or more stars stays recursive, with the token shape it had before this decision.
- Every other run (
a**,**b,a**/b,**.ts) is ordinary stars, read exactly as withrecursive_double_stardisabled:- component-local under
is_match_glob_path; - the position rule under
is_match_path: only a wildcard directly behind a separator is local; - separator-crossing like any
*underis_match.
- component-local under
Consequences
fastglob-034flips:fast-globand Ferralk agree on it. Theferralk_vs_fast_globfuzz subset now admits every whole-component**, except two shapes that still differ: the trailing/**elision (fastglob-033) and whole-component runs of three or more stars. It also admits every attached run.Walker includes and excludes select and prune by whole components.
**/node_modules/**no longer coversmy_node_modules.The zlob verdicts that now differ are recorded in
globstar-*withadr: "0020". They come from zlob's segment-wisematchPaths:- an attached run crossing separators under
is_match(a**/yagainsta/x/y,**.tsagainstsrc/a.ts); - the list filter's attached
a**/y; - recursive
**inside a whole-component extglob group (@(**)/y,@(**/x|z)); - a trailing
**/without its separator (a/**/againsta/b).
None of the previously recorded zlob verdicts changed.
- an attached run crossing separators under
The consumer-visible contract changes before 1.0; the compatibility guide's contract-change audit carries the entry.