fix(cli): os test walks the tree lazily and prunes, instead of listing the whole repository first (#7363) - #7489
Conversation
…ing the whole repository first (#7363) `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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7363
os testdocuments**inresolveGlob'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 apnpm install-ed worktree of this monorepo:main@afdc6ea, builtdist)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 loaded before it died.The
afterrun finds the 3*.test.jsonfiles actually in the tree —packages/{client,metadata-core,spec}/tsconfig.test.json— and refuses each at load time as not-a-suite (the #6247TestSuiteSchemagate working as designed: they are tsconfigs with comments). 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
resolveGlobsplit the pattern at the first wildcard to get a static base dir, so a leading**left the base at.— and then calledfs.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 pnpmnode_modulesis 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 treefindreports as 97 048 real entries (10 432 outsidenode_modules) exhausted 8 GB.What changed
packages/cli/src/commands/test.ts—resolveGlobonly. The walk is now lazy and segment-directed: pattern segments compile 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 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.node_modulesis 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_modulesand.gitare foreign trees.distandbuildare generated, so a suite found there is a stale copy of one that also lives in source — andos testdoes not merely list it, it loads and runs it against a live server. Without an ignore list that was true of a vendored suite innode_modulestoo.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
statSyncpass is gone.Dirent(withFileTypes: true) already answers file-vs-directory during the walk. Symlinks are the only entries that still cost astatSync, becauseDirentreports them as neither — and the old pass did count symlinks-to-files as matches, so that is preserved rather than quietly dropped.Decisions the card did not specify
Three 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.
/was folded throughpath.joinas an ordinary segment, so/tmp/x/*.test.jsonresolved against the cwd astmp/xand silently matched nothing. Fixing this is also what let the new tests point at amkdtempfixture instead ofprocess.chdir-ing the vitest worker.*and**are wildcards. The old translation escaped dots and nothing else, so every other regex metacharacter in a filename reached theRegExpas an operator:a+b.test.jsondid not match itself, anda?.jsonmeant "optionala" and matched.json.**segments are collapsed at compile time, so**/**/xdoes not reach the same directory down two pattern paths.Dropped deliberately: the
fs.existsSync(baseDir)pre-check. The walk's ownreaddirSyncis what has to survive a base that is missing, unreadable, or a plain file — and it does, inside atry. The pre-check was a second answer to the same question, and the one that goes stale between the check and the read.Docs:
content/docs/deployment/cli.mdx#### os testnow states the glob semantics and the prune list, since a silently-skipped suite inbuild/is exactly the surprise that has to be written down. Nothing undercontent/docs/releases/.The tests, and proof they can fail
packages/cli/test/resolve-glob-lazy-walk.test.ts— 15 cases. This repo has no Quality Protocol suites, so the tests build their own fixture tree undermkdtemp: source suites at three depths, decoy suites insidenode_modules, a nestedpackages/a/node_modules,dist,buildand.git, a directory nameddecoy.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 thatrecursiveis 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, each run 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: truerestored (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 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/catchrather than by theexistsSyncit appeared to test. The test was rewritten to reach the pruned name through a literal segment the walk itself matches, and the redundantexistsSyncwas removed.One thing worth passing on
The first full-suite run failed
format-zod-union.test.ts's--jsonpurity pin with a JSON parse error — caused by this PR's own doc comment. It contained the examplepackages/*/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 --noEmitandeslintwere both green on it. It surfaced only asReferenceError: dist is not definedfrom oclif's command discovery, printed to stdout, corrupting the--jsonpayload 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-wideeslint . --no-inline-config) — cleanpnpm typecheck(turbo, 126 tasks) — all successfulpnpm --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— green.changeset/os-test-glob-lazy-walk.md(@objectstack/cli: patch)Scope held:
packages/core/src/qa/runner.ts(#7256 / PR #7348) is untouched.Generated by Claude Code