Skip to content

Commit 398e907

Browse files
fix(scripts): error-status conformance walk skips an entry that vanished between readdir and stat (#20267)
Fixes #20225 Clause-②: no ## What changed One file: `scripts/check-error-status-conformance.mjs`. - `walk()` now stats each listed entry through `statListedEntry()`. That function tolerates exactly one error: `ENOENT` from the stat of an entry that the parent's `readdirSync` just listed, and only when an `lstat` cannot find the entry either. In that case the entry VANISHED and is skipped. Every other error still throws. - `--self-test` gains battery 28, with 10 cases on real disk. The roster floor goes from 28 to 29 and the case total from 60 to 70. - `deriveWireFace()` keeps its signature, so `check-error-status-conformance.d.mts` is unchanged. - No package source changes and no published surface moves. Root scripts live in the private root package, so this PR carries no changeset. ## Why: the mechanism, measured - `packages/spec/src/api/error-catalog-docs.test.ts` calls `deriveWireFace()` from `@objectstack/spec#test:repo`. CI's `Test Core` job runs `pnpm turbo run test test:repo` (`.github/workflows/ci.yml`). That turbo invocation also builds packages while the walk runs, because the test tasks depend on `^build`. - tsup 8.5.1 loads its config through bundle-require 5.1.0. bundle-require writes `tsup.config.bundled_ID.mjs` next to the config, imports it and unlinks it (`getOutputFile` and `unlink` in `bundle-require/dist/index.js`). Vite 8.0.16 writes its bundled config under `node_modules/.vite-temp/` instead, and the walk skips `node_modules`. - The old `walk()` called `readdirSync` and then a bare `statSync` on each entry. An entry the listing named could be gone before its stat ran. ## Reproduced first, on `origin/main` The probe lives in the scratchpad and is not committed. A sibling process loops create and unlink of `packages/spec/tsup.config.bundled_probe.mjs`, while `deriveWireFace(worktree)` runs in the parent. The "before" module is a byte-identical copy of the script at `443b2f4f` (blob `e31c273b`). The "after" module is a copy of this PR's head `1c249be6` (blob `309db59a`, equal to `git rev-parse HEAD:scripts/check-error-status-conformance.mjs`). Both legs ran under `scripts/pm/os-verify-lock.sh`. | leg | module | runs | ok | ENOENT | |---|---|---|---|---| | control, no churn | `443b2f4f` | 1 | 1 | 0 | | churn | `443b2f4f` | 8 | 4 | **4**: `ENOENT: no such file or directory, stat '…/packages/spec/tsup.config.bundled_probe.mjs'` at `walk (check-error-status-conformance.mjs:1832:15)` | | churn | `1c249be6` (this PR) | **24** | **24** | **0** | Every run that finished read **2674** sources, in all three legs. The churner was stopped after each leg, and no probe file was left behind (`leftBehind: false` in both churn legs). ## The rule: what it skips and what still throws | case | answer | pinned by | |---|---|---| | a listed entry that is gone (stat ENOENT, then lstat ENOENT) | skipped | 28, 28b, 28c | | absent at the stat, back under the same name by the lstat (not a symlink) | scanned as present; lstat IS its stat | 28d | | a dangling symlink (stat ENOENT, lstat finds the link) | **throws** `ENOENT` from `stat`. Skipping it would shrink the corpus, the same case `check-startup-registry-verdict.mjs` pins as "an error, not a smaller corpus" | 28e | | `ENOTDIR` | **throws** (produced for real: the listed directory is replaced by a file) | 28f | | `EACCES` on the stat | **throws** (injected: root bypasses permission bits, and CI and this container run as root) | 28g | | `ENOENT` on the stat, then any other error on the lstat | **throws** that error (injected) | 28h | | a missing scan ROOT | **throws** `ENOENT` naming the root | 28i | | nothing vanishing | every source-shaped file is read, including the one the churn deleted | 28j | - The rule does not match on names, because the next transient file will have a different one. - `statSync(p, { throwIfNoEntry: false })` is not this rule. Measured on Node v22.22.2, it also answers `undefined` for `ENOTDIR` and for a dangling symlink. - The header states what is **not** covered. A listed directory that vanishes before its own `readdirSync` still throws, and so does a pushed `.ts` file that vanishes before `readFileSync`. No producer of either was found under the scan root. The self-test's success line gains this clause, quoted verbatim: > … and the corpus walk skips an entry that VANISHED between readdir and stat (every present file still scanned) while a dangling symlink, ENOTDIR, a permission error and a missing root still throw. ### The pins can fail: five ablations For each ablation the fix was committed first. The mutation went through `node scripts/ablation-replace.mjs` in wrap mode, where the anchor must hit once and the blob must change. After each run the file was restored to the HEAD blob `309db59a`, and `git diff HEAD` came back empty. | ablation | expected red | observed | |---|---|---| | 1 vanished → rethrow (no tolerance) | 28, 28b, 28c | `3/70 case(s) failed`: 28, 28b (`enoent=1`), 28c (`no corpus`) | | 2 drop the dangling-symlink throw | 28e | `1/70`: 28e (`ENOENT open …/dangling.ts`: it failed at the read, not at the stat) | | 3 swallow every stat error | 28f, 28g | `2/70`: 28f, 28g | | 4 naive ENOENT swallow (no lstat) | 28d, 28e, 28h | `3/70`: 28d, 28e, 28h | | 5 tolerate ENOENT on the ROOT listing | 28i | `1/70`: 28i | ## Local verification (head `1c249be6`) - `node scripts/check-error-status-conformance.mjs --self-test`: `✓ … 70 cases pass`. - `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2 --reporter=verbose src/api/error-catalog-docs.test.ts`: `Test Files 1 passed (1)`, `Tests 5 passed (5)`. All five `error-catalog.mdx ↔ the published wire face` cases are green. - The gate families derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this branch are identical to the dispatch's list (30 commands). - All 30 were run at `1c249be6`. Every exit code was written to disk before it was read, and all 30 are `0`. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE` answers `Run reconciliation — 30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN` and `✓ … (a DERIVED zero — all 30 recorded an exit code and none of them is 3)`. The list includes `pnpm check:error-status-conformance` (self-test plus the real run), `check:entry-guard`, `check:parse-guard`, `check:declaration-mirrors` (+ `--self-test`), `check:self-test-wired` (+ `--self-test`) and `check:nul-bytes`. - The full CI farm is CI's job; this list is the derived subset, not an enumeration of `lint.yml`. ## Acceptance notes ### Census: every readdir-then-stat walker in `scripts/**` and `packages/*/scripts/**` Method: `git ls-files` over both globs gives 474 script files. Of those, 183 call `readdirSync`/`readdir(`, and 85 also call a stat. Every hit was read. A walker **can race a transient file** only when two things hold: it scans a tree that a build writes transient entries into (a package root or `dist/`), **and** it runs concurrently with that build. In CI, concurrency exists only inside a turbo invocation that mixes build and test tasks. So the second half was measured on these surfaces: package tests that import or spawn a repo script, package `build`/`test` npm scripts, and turbo `inputs`. **Fixed here (1).** `scripts/check-error-status-conformance.mjs` `walk()`. It scans `packages/` and runs inside `@objectstack/spec#test:repo`, and its race was reproduced above. **Same shape, over a tree a build writes to, but never concurrent with a build. Recorded, not fixed.** On every path that runs them, each is a sequential CI step (`lint.yml`, the type-check job) or a manual command. No package test or build script runs any of them on the real tree: the package tests that reach `check-regen-pending.mjs` pass sandbox directories. They would race only if someone ran the gate while a build ran in the same worktree. - `packages/` walks with a bare per-entry `statSync`: - `packages/spec/scripts/check-error-code-provenance.ts` `collectSourceFiles` - `scripts/check-dispatcher-error-vocabulary.mjs` `walkManifests`, `walkSources` - `scripts/check-doc-authoring.mjs` `collectPackageProseFiles` - `scripts/check-error-code-casing.mjs` `walk` - `scripts/check-init-service-contract.mjs` `walk` - `scripts/check-objectql-double-limit.mjs` `testFilesUnder` - `scripts/check-route-envelope.mjs` (two walks) - `scripts/check-runtime-services-index.mjs` `walk` - `scripts/check-settings-bind-window.mjs` `walk` - `scripts/check-single-authz-resolver.mjs` `walk` - `scripts/check-startup-registry-verdict.mjs` `collectSourceFiles` - `scripts/check-where-matcher-conformance.mjs` `testFilesUnder` - `scripts/check-wildcard-fallthrough.mjs` `walk` - Whole-repo walks: - `scripts/check-parse-guard.mjs` `walkOutside` - `scripts/check-watch-hint-literal.mjs` `walk` - Dirent walks that stat each matched file for its mtime: - `scripts/check-type-check-coverage.mjs` `newestSourceMtime` - `scripts/check-regen-pending.mjs` `newestMtime` (spec `dist/`, `json-schema/`, `src/`). Its real-tree callers are `build-docs.ts`, `check-generated.ts` and `lib/dist-freshness.ts`, and each runs after a build. - `packages/spec/scripts/check-generated.ts` `declarationFiles`. It reads `dist/` after the forced build it performs itself, in the same process. **Same shape, over trees no build writes to. Cannot race a build transient.** - `src`-only roots: - `packages/spec/scripts/build-docs.ts:136` - `packages/spec/scripts/category-title.test.ts:47` - `packages/spec/scripts/check-duration-unit-keys.ts` - `scripts/check-driver-conformance.mjs` `walkTsInto` - `scripts/check-kernel-hook-pairs.mjs` - `scripts/check-meta-type-normalized.mjs` - `scripts/check-sdui-lockstep.mjs` - `scripts/check-spec-parsed-alias.mjs` - `scripts/check-doc-authoring.mjs` `collectSpecSourceFiles` - the sibling-directory `find`s at `check-objectql-double-limit.mjs:1283` and `check-where-matcher-conformance.mjs:1018` - `packages/spec/scripts/check-llms-txt.ts` `readWorkspacePackages`, which stats listed package directories - Docs, skills, `.claude` and `.github` roots: - `scripts/check-corpus-claim-drift.mjs` - `scripts/check-doc-authoring.mjs` `walk`, `collectPublishedSkillFiles` - `scripts/check-doc-route-spelling.mjs` - `scripts/check-docs-spec-enumerations.mjs` - `scripts/check-role-word.mjs` - `scripts/check-skill-compatibility-version.mjs` - `scripts/check-skill-frame-sync.mjs` - `scripts/check-workflow-step-name-quoting.mjs` - `scripts/check-self-test-wired.mjs` `walkActionFiles` - `scripts/pm/check-skill-id-lint.mjs` - `scripts/pm/dispatch-gates.mjs` `tierValueSites` - `scripts/symbol-anchors.mjs` - `scripts/` roots: - `scripts/check-entry-guard.mjs` - `scripts/check-parse-guard.mjs` `walk` - `scripts/check-self-test-wired.mjs` `walkScripts` - `scripts/pm/measurement-claim-triage.mjs` - A manual codemod, run on a root the user names: `scripts/codemod/apimethods-legacy-to-primitives.mjs`. **Already tolerant, through a catch-all around the stat.** This is broader than `ENOENT`, which is a different concern from this card's: - `scripts/audits/14744-before-update-per-row-value-census.mjs` (two walks) - `scripts/check-cli-command-ids.mjs` (two walks) - `scripts/check-durability-degradation-log-level.mjs` - `scripts/check-logger-receiver-detach.mjs` - `scripts/check-optional-error-sink-contract.mjs` - `scripts/check-page-declaration-shape.mjs` - `scripts/codemod/view-to-viewitem.mjs` - `scripts/report-test-timings.mjs` - `scripts/publish-smoke.sh` - `packages/spec/scripts/analyze-bundle-size.ts` (`getFileSize`) **Not the shape.** The remaining files list entries with `readdirSync(…, { withFileTypes: true })`, so no stat runs per entry, or they stat only a root or a fixed path. That includes `packages/spec/scripts/lazify-schemas.ts`, `scripts/check-dts-closure.mjs` and `scripts/check-sourcemap-no-sources-content.mjs`, whose only stat is of the root before listing it. **Shared helper (Zone 2.4).** Only one walker needed the tolerance, so the three-walker threshold was not met. No new shared module was created, and `scripts/lib/**` holds no readdir/stat helper today (none of its files is among the 85). ### Outside this card's file surface (noted, not fixed) - Test files under `packages/**/src` that walk the repo with a bare readdir-then-stat are outside the census globs and outside the claim, which allows no `packages/**/src` change. 12 test files call both `readdirSync` and `statSync` on a repo-rooted path. One of them walks the whole of `packages/` from inside a turbo test task: `packages/lint/src/lint-startup-registry-verdict.corpus.test.ts` `collectSourceFiles(packagesDir)`, in `@objectstack/lint#test`. I extracted that function verbatim from the file, transpiled it with esbuild and ran it over the real `packages/` under the same churn. It threw `ENOENT … stat '…/packages/spec/tsup.config.bundled_probe.mjs'` in **4 of 8** runs, while the other 4 read 2688 files. It is the same mechanism as this card. It is reported to the seat for routing, and it is not fixed here. - `check-error-status-conformance.mjs`'s own `scanSources` reads each pushed file with `readFileSync`, and the recursion calls `readdirSync` on each listed directory. Neither is covered, as the header states, because no producer was found. The measured one (`tsup.config.bundled_ID.mjs`) is `.mjs` and is filtered out before the read. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1207baf commit 398e907

1 file changed

Lines changed: 263 additions & 11 deletions

File tree

‎scripts/check-error-status-conformance.mjs‎

Lines changed: 263 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,13 @@
153153
// standard code with ZERO derived producer is listed in
154154
// `scripts/error-status-unpinned-baseline.json`; a NEW one fails the gate, and a
155155
// row that becomes pinned fails it too (ratchet down with `--update`).
156-
import { readdirSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs';
156+
import {
157+
readdirSync, readFileSync, writeFileSync, statSync, lstatSync, existsSync,
158+
mkdtempSync, mkdirSync, rmSync, renameSync, symlinkSync, unlinkSync,
159+
} from 'node:fs';
160+
import { tmpdir } from 'node:os';
157161
import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs';
158-
import { join, relative } from 'node:path';
162+
import { basename, dirname, join, relative } from 'node:path';
159163
import { isEntrypoint } from './invoked-as.mjs';
160164

161165
// ── The self-test's own battery roster and floor (#13489) ──────────────────
@@ -203,11 +207,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
203207
'25 — `z.enum([...])` members: the ONE extra segment `lookup` walks, and the': 3,
204208
'26 — a code the DOOR TRANSLATES away is not a wire producer: derived from': 8,
205209
'27 — the vocabulary parse reads the enum ARRAY and stops at its bracket: an': 2,
210+
'28 — the corpus walk skips an entry that VANISHED between readdir and stat,': 10,
206211
});
207212

208213
// DELETING an entry silences that battery's floor exactly as effectively as
209214
// zeroing it, so the roster's own size is pinned too.
210-
const SELF_TEST_BATTERY_FLOOR = 28;
215+
const SELF_TEST_BATTERY_FLOOR = 29;
211216

212217
// The key an assertion is filed under when no battery is open. It is not a
213218
// declared battery, so it reds by the same set difference rather than silently
@@ -1751,7 +1756,179 @@ function selfTest() {
17511756
JSON.stringify(plainClose) === JSON.stringify(['TIMEOUT', 'VALIDATION_ERROR']),
17521757
JSON.stringify(plainClose));
17531758

1754-
const CASES = 60;
1759+
// 28 — the corpus walk skips an entry that VANISHED between readdir and stat,
1760+
// and nothing else (#20225). Real files on real disk under the OS temp
1761+
// dir. The seam only decides WHEN the unlink happens: after the listing
1762+
// names the entry and before the walk stats it. That is the window tsup's
1763+
// transient `tsup.config.bundled_<id>.mjs` hit in CI, made deterministic,
1764+
// so every error below is the one the kernel really returns. The halves
1765+
// that must still THROW are pinned beside the half that must not.
1766+
battery('28 — the corpus walk skips an entry that VANISHED between readdir and stat,');
1767+
const thrown = (fn) => {
1768+
try {
1769+
fn();
1770+
return null;
1771+
} catch (err) {
1772+
return err;
1773+
}
1774+
};
1775+
const walkRoot = mkdtempSync(join(tmpdir(), 'check-error-status-walk-'));
1776+
try {
1777+
const pkg = join(walkRoot, SCAN_ROOT, 'spec');
1778+
const bundle = join(pkg, 'tsup.config.bundled_selftest.mjs');
1779+
const transientTs = join(pkg, 'src', 'transient.ts');
1780+
const seedTree = () => {
1781+
rmSync(join(walkRoot, SCAN_ROOT), { recursive: true, force: true });
1782+
mkdirSync(join(pkg, 'src', 'deep'), { recursive: true });
1783+
writeFileSync(join(pkg, 'src', 'kept.ts'), 'export const KEPT = 1;\n');
1784+
writeFileSync(join(pkg, 'src', 'deep', 'nested.ts'), 'export const NESTED = 2;\n');
1785+
writeFileSync(bundle, 'export default {};\n');
1786+
writeFileSync(transientTs, 'export const TRANSIENT = 3;\n');
1787+
};
1788+
const PRESENT = {
1789+
'packages/spec/src/deep/nested.ts': 'export const NESTED = 2;\n',
1790+
'packages/spec/src/kept.ts': 'export const KEPT = 1;\n',
1791+
};
1792+
const corpusOf = (sources) => JSON.stringify(Object.fromEntries(sources));
1793+
// The churn: a listing that names `doomed` entries, which are then really
1794+
// unlinked before the walk stats them. `enoent` counts the stats the kernel
1795+
// answered ENOENT, so the positive control can prove the race was hit.
1796+
const churn = (doomed) => {
1797+
const seen = { enoent: 0 };
1798+
const io = {
1799+
readdirSync(dir) {
1800+
const names = readdirSync(dir);
1801+
for (const d of doomed) if (dirname(d) === dir && names.includes(basename(d))) unlinkSync(d);
1802+
return names;
1803+
},
1804+
statSync(p) {
1805+
try {
1806+
return statSync(p);
1807+
} catch (err) {
1808+
if (err?.code === 'ENOENT') seen.enoent++;
1809+
throw err;
1810+
}
1811+
},
1812+
lstatSync,
1813+
};
1814+
return { io, seen };
1815+
};
1816+
1817+
seedTree();
1818+
const churned = churn([bundle, transientTs]);
1819+
let churnedSources = null;
1820+
const churnErr = thrown(() => { churnedSources = scanSources(walkRoot, churned.io); });
1821+
check('28 a scan that loses tsup\'s bundle and a source-shaped file mid-walk completes',
1822+
churnErr === null, `${churnErr?.code} ${churnErr?.syscall} ${churnErr?.path}`);
1823+
check('28b POSITIVE CONTROL: the stat of both vanished entries really answered ENOENT',
1824+
churned.seen.enoent === 2, `enoent=${churned.seen.enoent}`);
1825+
check('28c every PRESENT file is still scanned, with its text, and the vanished ones are not',
1826+
churnedSources !== null && corpusOf(churnedSources) === JSON.stringify(PRESENT),
1827+
churnedSources === null ? 'no corpus' : corpusOf(churnedSources));
1828+
1829+
// Absent at the stat, back under the same name before the lstat: the entry
1830+
// is present, and `lstat` IS the stat of a non-symlink, so it is scanned.
1831+
seedTree();
1832+
const back = {
1833+
readdirSync,
1834+
statSync(p) {
1835+
if (p !== transientTs) return statSync(p);
1836+
unlinkSync(p);
1837+
try {
1838+
return statSync(p);
1839+
} finally {
1840+
writeFileSync(p, 'export const TRANSIENT = 3;\n');
1841+
}
1842+
},
1843+
lstatSync,
1844+
};
1845+
let backSources = null;
1846+
const backErr = thrown(() => { backSources = scanSources(walkRoot, back); });
1847+
check('28d an entry absent at the stat and back by the lstat is scanned as present',
1848+
backErr === null && backSources?.get('packages/spec/src/transient.ts') === 'export const TRANSIENT = 3;\n',
1849+
backErr ? `${backErr.code} ${backErr.path}` : corpusOf(backSources));
1850+
1851+
// A dangling symlink: `stat` answers ENOENT, `lstat` finds the link. The entry
1852+
// EXISTS, so this is a partly-read corpus and it throws.
1853+
seedTree();
1854+
symlinkSync(join(walkRoot, 'no-such-target.ts'), join(pkg, 'src', 'dangling.ts'));
1855+
const danglingErr = thrown(() => scanSources(walkRoot));
1856+
check('28e a dangling symlink still throws ENOENT from its stat, not a smaller corpus',
1857+
danglingErr?.code === 'ENOENT' && danglingErr?.syscall === 'stat'
1858+
&& danglingErr?.path === join(pkg, 'src', 'dangling.ts'),
1859+
`${danglingErr?.code} ${danglingErr?.syscall} ${danglingErr?.path}`);
1860+
1861+
// ENOTDIR, for real: the listed directory is replaced by a FILE between the
1862+
// listing and the stat of its first entry.
1863+
seedTree();
1864+
const deep = join(pkg, 'src', 'deep');
1865+
const notDir = {
1866+
readdirSync(dir) {
1867+
const names = readdirSync(dir);
1868+
if (dir === deep) {
1869+
renameSync(deep, `${deep}.moved`);
1870+
writeFileSync(deep, 'not a directory');
1871+
}
1872+
return names;
1873+
},
1874+
statSync,
1875+
lstatSync,
1876+
};
1877+
const notDirErr = thrown(() => scanSources(walkRoot, notDir));
1878+
check('28f ENOTDIR still throws',
1879+
notDirErr?.code === 'ENOTDIR', `${notDirErr?.code} ${notDirErr?.syscall} ${notDirErr?.path}`);
1880+
rmSync(deep, { force: true });
1881+
rmSync(`${deep}.moved`, { recursive: true, force: true });
1882+
1883+
// EACCES cannot be produced for real here: root bypasses permission bits, and
1884+
// CI and this container both run as root. So the code is injected, on the
1885+
// stat and then on the confirming lstat.
1886+
seedTree();
1887+
const denied = (code) => Object.assign(new Error(`${code}: injected by --self-test`), { code });
1888+
const eaccesErr = thrown(() => scanSources(walkRoot, {
1889+
readdirSync,
1890+
statSync(p) {
1891+
if (p === join(pkg, 'src', 'kept.ts')) throw denied('EACCES');
1892+
return statSync(p);
1893+
},
1894+
lstatSync,
1895+
}));
1896+
check('28g a permission error on the stat still throws',
1897+
eaccesErr?.code === 'EACCES', `${eaccesErr?.code}`);
1898+
const confirmErr = thrown(() => scanSources(walkRoot, {
1899+
readdirSync,
1900+
statSync(p) {
1901+
if (p === join(pkg, 'src', 'kept.ts')) throw Object.assign(new Error('ENOENT: injected'), { code: 'ENOENT' });
1902+
return statSync(p);
1903+
},
1904+
lstatSync(p) {
1905+
if (p === join(pkg, 'src', 'kept.ts')) throw denied('EACCES');
1906+
return lstatSync(p);
1907+
},
1908+
}));
1909+
check('28h an ENOENT whose lstat check fails any other way still throws that error',
1910+
confirmErr?.code === 'EACCES', `${confirmErr?.code}`);
1911+
1912+
// A missing ROOT: no listing named it, so no rule here reaches it.
1913+
const bare = join(walkRoot, 'no-packages-here');
1914+
mkdirSync(bare);
1915+
const rootErr = thrown(() => scanSources(bare));
1916+
check('28i a missing scan ROOT still throws ENOENT naming it',
1917+
rootErr?.code === 'ENOENT' && rootErr?.path === join(bare, SCAN_ROOT),
1918+
`${rootErr?.code} ${rootErr?.syscall} ${rootErr?.path}`);
1919+
1920+
// The default seam, no churn: every source-shaped file is read, the one that
1921+
// vanished above included. The rule is about vanishing, never about names.
1922+
seedTree();
1923+
const plain = scanSources(walkRoot);
1924+
check('28j with nothing vanishing, the default walk reads every source-shaped file',
1925+
corpusOf(plain) === JSON.stringify({ ...PRESENT, 'packages/spec/src/transient.ts': 'export const TRANSIENT = 3;\n' }),
1926+
corpusOf(plain));
1927+
} finally {
1928+
rmSync(walkRoot, { recursive: true, force: true });
1929+
}
1930+
1931+
const CASES = 70;
17551932
// ── The floor: every declared battery RAN, and ran its cases (#13489) ───
17561933
//
17571934
// Evaluated after every battery has had its chance and BEFORE the verdict, so
@@ -1814,7 +1991,9 @@ function selfTest() {
18141991
+ 'named a producer or a removed doc entry — never the wrong one of the two, a `z.enum([...])` member resolves '
18151992
+ 'through `NAME.enum.MEMBER` while a name that array does not list stays unresolved, and a class whose code a '
18161993
+ 'DOOR TRANSLATES away is no wire producer — reported, kept out of the unpinned census, with the arm-less '
1817-
+ 'negative control still producing and an untranslated code untouched.',
1994+
+ 'negative control still producing and an untranslated code untouched, and the corpus walk skips an entry that '
1995+
+ 'VANISHED between readdir and stat (every present file still scanned) while a dangling symlink, ENOTDIR, a '
1996+
+ 'permission error and a missing root still throw.',
18181997
);
18191998
selfTestReachedVerdict = true;
18201999
process.exit(0);
@@ -1825,12 +2004,84 @@ function selfTest() {
18252004
// The real check
18262005
// ───────────────────────────────────────────────────────────────────────────
18272006

1828-
function walk(dir, out) {
1829-
for (const e of readdirSync(dir)) {
2007+
// ── The walk, and the ONE error it tolerates ──────────────────────────────
2008+
//
2009+
// The walk runs inside turbo, not only in the lint job:
2010+
// `packages/spec/src/api/error-catalog-docs.test.ts` calls `deriveWireFace()`
2011+
// from `@objectstack/spec#test:repo`, and the same turbo run builds packages.
2012+
// tsup (through bundle-require) writes `tsup.config.bundled_<random>.mjs`
2013+
// beside each package's config, imports it, and deletes it. An entry that
2014+
// `readdirSync` listed can therefore be gone by the time the walk stats it,
2015+
// and the bare `statSync` threw `ENOENT` and turned the required `Test Core`
2016+
// red with every test passing (#20225).
2017+
//
2018+
// Tolerated: `ENOENT` from the stat of an entry the parent listing just named,
2019+
// and only when an `lstat` cannot find the entry either. Then the entry
2020+
// VANISHED and there is nothing to scan. Everything else still throws:
2021+
// • a missing ROOT. No listing named it, so the scan subject itself is gone;
2022+
// • a dangling symlink. `stat` answers `ENOENT` while `lstat` finds the link,
2023+
// so the entry EXISTS and the corpus would be read only in part.
2024+
// `check-startup-registry-verdict.mjs` pins the same case as "an error, not
2025+
// a smaller corpus";
2026+
// • `ENOTDIR`, `EACCES` and every other code.
2027+
// A present entry is stat'ed exactly as before, so no real source file can
2028+
// leave the corpus through this rule. It does not match on names: the next
2029+
// transient file will have a different one. `statSync(p, { throwIfNoEntry:
2030+
// false })` is NOT this rule. Measured on Node 22, it also answers `undefined`
2031+
// for `ENOTDIR` and for a dangling symlink.
2032+
//
2033+
// Only the per-entry stat is covered. A listed directory that vanishes before
2034+
// its own `readdirSync`, and a pushed source file that vanishes before
2035+
// `readFileSync`, still throw. No producer of a transient directory or a
2036+
// transient `.ts`/`.tsx` file under the scan root was found (Vite 8 writes its
2037+
// bundled config under `node_modules/.vite-temp/`, which the walk skips).
2038+
// Extend this rule there when one appears. Never extend it to a retry.
2039+
2040+
/**
2041+
* The filesystem calls the walk makes, gathered into one seam. `--self-test`
2042+
* (battery 28) passes a copy whose calls delete an entry at an exact point
2043+
* between the listing and the stat. A race reproduced by timing would be a
2044+
* flaky self-test.
2045+
*/
2046+
const WALK_FS = Object.freeze({ readdirSync, statSync, lstatSync });
2047+
2048+
/**
2049+
* `statSync(p)` for an entry the walk's own listing named, or `null` when that
2050+
* entry VANISHED since. The header above states the rule and what it does not
2051+
* cover.
2052+
*
2053+
* @param {string} p the listed entry's path
2054+
* @param {typeof WALK_FS} io
2055+
* @returns {import('node:fs').Stats | null}
2056+
*/
2057+
function statListedEntry(p, io) {
2058+
try {
2059+
return io.statSync(p);
2060+
} catch (err) {
2061+
if (err?.code !== 'ENOENT') throw err;
2062+
let entry;
2063+
try {
2064+
entry = io.lstatSync(p);
2065+
} catch (again) {
2066+
if (again?.code === 'ENOENT') return null; // vanished: nothing left to scan
2067+
throw again;
2068+
}
2069+
// Present after all. A symlink here is dangling: the entry exists, its
2070+
// target does not, and skipping it would shrink the corpus, so it throws.
2071+
if (entry.isSymbolicLink()) throw err;
2072+
// Any other entry was absent at the stat and is back under the same name.
2073+
// For a non-symlink, `lstat` IS its `stat`, so it is scanned as present.
2074+
return entry;
2075+
}
2076+
}
2077+
2078+
function walk(dir, out, io = WALK_FS) {
2079+
for (const e of io.readdirSync(dir)) {
18302080
if (SKIP_DIRS.has(e)) continue;
18312081
const p = join(dir, e);
1832-
const s = statSync(p);
1833-
if (s.isDirectory()) walk(p, out);
2082+
const s = statListedEntry(p, io);
2083+
if (s === null) continue;
2084+
if (s.isDirectory()) walk(p, out, io);
18342085
else if (/\.tsx?$/.test(e) && !/\.(test|spec|d)\.tsx?$/.test(e)) out.push(p);
18352086
}
18362087
}
@@ -1840,11 +2091,12 @@ function walk(dir, out) {
18402091
* under `repoRoot`, keyed by repo-relative path.
18412092
*
18422093
* @param {string} repoRoot directory the scan root is resolved against
2094+
* @param {typeof WALK_FS} [io] the walk's filesystem seam — `--self-test` only
18432095
* @returns {Map<string, string>} repo-relative path → source text
18442096
*/
1845-
function scanSources(repoRoot) {
2097+
function scanSources(repoRoot, io = WALK_FS) {
18462098
const files = [];
1847-
walk(join(repoRoot, SCAN_ROOT), files);
2099+
walk(join(repoRoot, SCAN_ROOT), files, io);
18482100
const sources = new Map();
18492101
for (const f of files.sort()) sources.set(relative(repoRoot, f).replace(/\\/g, '/'), readFileSync(f, 'utf8'));
18502102
return sources;

0 commit comments

Comments
 (0)