Commit 072ab7f
`os test` documents `**` in `resolveGlob`'s own header, and a `**` pattern was
the one thing it could not survive.
## The repro, measured before and after
`node packages/cli/bin/run.js test '**/*.test.json'`, from the repository root
of a `pnpm install`-ed worktree of this monorepo:
- **Before** (`main` @ `afdc6ea`, built `dist`): **exit 134** — `FATAL ERROR:
Ineffective mark-compacts near heap limit`, after **440 s** (7 m 20 s) of GC
thrash at an 8 GB heap. Not one suite was loaded before it died.
- **After**: **completes in 2–3 s**, exit 1. It finds the 3 `*.test.json` files
that are actually in the tree — `packages/{client,metadata-core,spec}/
tsconfig.test.json` — and refuses each at load time as not-a-suite (the #6247
`TestSuiteSchema` gate, working as designed: they are tsconfigs with comments,
not Quality Protocol suites). Exit 1 is the correct verdict for "3 files
matched, 3 were not suites"; the point of the measurement is that the command
now *reaches* a verdict.
Isolated confirmation of the mechanism, same worktree:
`fs.readdirSync('.', { recursive: true })` alone OOMs at 479 s.
## Why it died
`resolveGlob` split the pattern at the first wildcard to get a static base dir,
so a leading `**` left the base at `.` — and then called
`fs.readdirSync(baseDir, { recursive: true })`, which **materialises every path
under the base as one array before any filtering runs**. The filter that would
have discarded almost all of them never got to run.
The array was worse than one-entry-per-file, and this is the part the card did
not have: **`readdirSync(recursive)` follows symlinked directories** (verified
directly — a symlinked dir's contents appear in the result under the link's
name). A pnpm `node_modules` is a symlink graph in which every package links to
its dependencies' real directories, so the walkable path set is combinatorial in
dependency depth, not linear in file count. That is how a tree `find` reports as
**97 048 real entries** (10 432 outside `node_modules`) exhausted 8 GB.
## What changed
`packages/cli/src/commands/test.ts` — `resolveGlob` only. The walk is now lazy
and segment-directed: pattern segments are compiled to per-segment matchers, and
the walk reads one directory at a time, descending only where the remaining
pattern can still be satisfied. Nothing is ever accumulated in order to be
discarded, and symlinked directories are not descended into — which removes the
combinatorial blow-up along with any cycle risk.
**Prune list: `node_modules`, `.git`, `dist`, `build`** (the four the card named).
`node_modules` is the one that made the command unusable; all four are the same
claim — a wildcard is a search of *your* sources, and none of these holds one.
`node_modules` and `.git` are foreign trees. `dist` and `build` are generated, so
a suite found there is a stale copy of one that also lives in source, and
`os test` does not merely list it: it **loads and runs it against a live
server**. Without an ignore list that was true of a vendored suite in
`node_modules` too.
The prune applies only to directories a *wildcard* reached. A pattern that spells
the name out still walks it, because naming a directory is asking for it and a
list of defaults must not overrule the argument you typed.
**The second `statSync` pass is gone.** `Dirent` (`withFileTypes: true`) already
answers file-vs-directory during the walk. Symlinks are the only entries that
still cost a `statSync`, because `Dirent` reports them as neither — and the old
pass did count symlinks-to-files as matches, so that is preserved rather than
quietly dropped.
## Decisions the dispatch did not specify
Three of these are behaviour changes beyond "walk lazily". Each is a case where
the old code was wrong rather than merely slow, and each is pinned by a test.
1. **Absolute patterns now resolve absolutely.** A leading `/` was folded through
`path.join` as an ordinary segment, so `/tmp/x/*.test.json` resolved against
the cwd as `tmp/x` and silently matched nothing. Fixing this was also what let
the new tests point at a `mkdtemp` fixture instead of `process.chdir`-ing the
vitest worker.
2. **Only `*` and `**` are wildcards.** The old translation escaped dots and
nothing else, so every other regex metacharacter in a filename reached the
`RegExp` as an operator: `a+b.test.json` did not match itself, and `a?.json`
meant "optional `a`" and matched `.json`. All metacharacters but `*` are now
escaped.
3. **Results are sorted and de-duplicated**, so suites run in the same order on
every filesystem. Adjacent `**` segments are also collapsed at compile time,
so `**/**/x` does not reach the same directory down two pattern paths.
Dropped, deliberately: the `fs.existsSync(baseDir)` pre-check. The walk's own
`readdirSync` is what has to survive a base that is missing, unreadable, or a
plain file — and it does, inside a `try`. The pre-check was a second answer to
the same question, and the one that goes stale between the check and the read.
Verified by mutation: removing the `try`/`catch` turns the missing-base test red,
removing the `existsSync` alone changes nothing.
Docs: `content/docs/deployment/cli.mdx` `#### os test` now states the glob
semantics and the prune list, since a silently-skipped suite in `build/` is
exactly the surprise that has to be written down. (Nothing under
`content/docs/releases/`.)
## The tests, and proof they can fail
`packages/cli/test/resolve-glob-lazy-walk.test.ts` — 15 cases. As the dispatch
warned, this repo has **no** Quality Protocol suites, so the tests build their own
fixture tree under `mkdtemp`: source suites at three depths, decoy suites inside
`node_modules`, a nested `packages/a/node_modules`, `dist`, `build` and `.git`, a
directory *named* `decoy.test.json`, a filename with a regex metacharacter, and a
symlinked directory pointing back up the tree. A test that passed by finding
nothing would prove nothing, so every assertion names the files it expects.
The laziness pins do not test the result, they test the walk: `vi.spyOn(fs,
'readdirSync')` asserts no pruned directory is ever *read* (the prune is a walk
decision, not a post-filter) and that `recursive` is never requested;
`vi.spyOn(fs, 'statSync')` asserts the second pass is gone.
Every one of the 15 was driven red by mutating the source and green by reverting.
The mutations run, each in isolation: empty prune list (2 red) · descend into
symlinked dirs (4 red) · drop sort+dedup (1 red) · escape-dots-only (1 red) ·
stat every survivor (1 red) · `recursive: true` restored (7 red) · absolute base
folded as relative (11 red) · no-wildcard fast path removed (1 red) · prune
overrules an explicit literal segment (1 red) · readdir error uncaught (1 red) ·
collapse+dedup both removed (1 red).
Two mutations initially **survived**, and both were real slack that this found:
the "walks a pruned dir when you spell it out" case only exercised names sitting
in the *static base* (peeled off before the walk, so never offered to the prune
at all), and the missing-base case was covered by the `try`/`catch` rather than
by the `existsSync` it appeared to test. The test was rewritten to reach the
pruned name through a literal segment the walk itself matches, and the redundant
`existsSync` was removed.
## One thing worth passing on
The first full-suite run failed `format-zod-union.test.ts`'s `--json` purity pin
with a JSON parse error — caused by **this commit's own doc comment**. It
contained the example `packages/*/dist/…`, whose `*/` terminated the block
comment early; the rest of the prose became code, and the residue happened to
parse (a division, then a template literal opened by a backtick in the next
comment). `tsc --noEmit` and `eslint` were both green on it. It surfaced only as
`ReferenceError: dist is not defined` from oclif's command discovery, printed to
stdout, corrupting the `--json` payload of an *unrelated* command. The pin caught
it; the comment is rephrased. Worth knowing that a stray `*/` in a doc example
is invisible to the type and lint gates and lands as output pollution.
## Gates
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) — clean
- `pnpm typecheck` (turbo, 126 tasks) — all successful
- `pnpm --filter @objectstack/cli test` — 109 files, **1182 passed, 0 failed**
(the pre-fix run of the same suite was 1147 tests with 4 failed files; the
difference is the comment bug above, which aborted three files at import)
- `check:empty-changeset`, `check:adr-0087-registration`, `check:nul-bytes`,
`check:doc-authoring`, `check:docs-audit-scope` — all green
- Changeset: `.changeset/os-test-glob-lazy-walk.md` (`@objectstack/cli`: patch)
Fixes #7363
Claude-Session: https://claude.ai/code/session_01G4J3CVg3cRVnZ9QCL2KsQY
Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf7c694 commit 072ab7f
4 files changed
Lines changed: 424 additions & 29 deletions
File tree
- .changeset
- content/docs/deployment
- packages/cli
- src/commands
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1017 | 1017 | | |
1018 | 1018 | | |
1019 | 1019 | | |
| 1020 | + | |
1020 | 1021 | | |
1021 | 1022 | | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
1022 | 1032 | | |
1023 | 1033 | | |
1024 | 1034 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
11 | 74 | | |
12 | 75 | | |
13 | 76 | | |
14 | 77 | | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
15 | 96 | | |
16 | | - | |
| 97 | + | |
17 | 98 | | |
18 | 99 | | |
19 | 100 | | |
20 | 101 | | |
21 | 102 | | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
| 103 | + | |
26 | 104 | | |
27 | | - | |
| 105 | + | |
| 106 | + | |
28 | 107 | | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
35 | 131 | | |
36 | 132 | | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
54 | 179 | | |
55 | 180 | | |
56 | 181 | | |
| |||
0 commit comments