Skip to content

Commit 10ea9eb

Browse files
test(lint): skip a vanished entry in the startup-registry corpus walk (#20303)
Fixes #20269 Clause-②: no ## Rework round 2 The order of record is the seat's comment `5859739814` on #20269. It rules **E**: the dev's option D (restore the fs walk and extend the vanished-entry rule to the two legs round 1 measured open), plus `tmp` in `SKIP_DIRS`. Head: `5327f65679`. Commits this round: `e3d892116a`, `5327f65679`, both on top of the branch with no rebase and no force-push. ### What changed All changes are in `packages/lint/src/lint-startup-registry-verdict.corpus.test.ts`: - **The stage-1 walk is back** (from `606510b23c`), with `statListedEntry` and its `stat`-and-`lstat` rule verbatim. The ACCEPTED_WALK_RADII entry for this test stays exactly as it is. - **The same rule now covers the two reads after the stat:** - `readListedDirectory(dir)` covers a listed directory's `readdirSync`; - `readCollectedFile(file)` covers the sweep's `readFileSync`, and the `beforeAll` sweep now reads through it. - Both ask `absentAtRead(err, path)`, which returns true only for `ENOENT` when an `lstat` does not find a symlink. - `ENOTDIR`, `EACCES` and every other code rethrow. So does a dangling symlink, where the read answers `ENOENT` but `lstat` finds the link. So does a missing ROOT: the root's own `readdirSync` is bare. - There is no retry. - One detail, fixed in `5327f65679`: an entry absent at the read and back under the same name by the `lstat` is skipped as absent at the read. Re-reading it would be a retry. Measured below: `e3d892116a` threw on that case and the order's "skip on ENOENT only" does not. - The stat leg alone keeps such an entry as present, as in stage 1, because its `lstat` IS the stat. - **`tmp` is in `SKIP_DIRS`.** The root `.gitignore` ignores `tmp/`, and `git ls-files -- packages | grep -E '(^|/)tmp/'` counts **0** tracked paths on this head. So nothing authored leaves the corpus, and the `packages/cli/tmp` fixtures never enter it. - The file header's false-green 1 and each helper's doc state the tolerated error and what still throws. ### Corpus count Head lists **2690** files. The set is identical to the stage-1 walk at `606510b23c` on the same quiet tree (`comm -3` prints nothing). ### Probes on head `5327f65679`, 100 runs each The harness extracts the walk verbatim per revision (`git show`), transpiles it with the repo's TypeScript, walks the real tree, then reads every collected file through the revision's own read helper, as the suite's `beforeAll` does. | probe | revision | threw | transient in corpus | race hit and absorbed | |---|---|---|---|---| | (a) tsup create/unlink `packages/spec/tsup.config.bundled_probe.mjs` | base `3f86dc52f2` (control) | 23/100, `ENOENT stat` | n/a | n/a | | (a) tsup | **head** | **0/100** | n/a | 30 runs | | (b) sibling mkdir / write `objectstack.config.ts` / `rm -r` under `packages/cli/tmp/tmp-probe` | stage 1 `606510b23c` (control) | 31/100 (26 `ENOENT open`, 5 `ENOENT scandir`) | 67/100 | n/a | | (b) fixture | **head** | **0/100** | **0/100** | never listed (`tmp` skipped) | | (c) the same loop under a NOT-ignored `packages/cli/zz-probe-unignored` (the accepted residual) | stage 1 `606510b23c` (control) | 25/100 | 71/100 | n/a | | (c) residual | `e3d892116a` | 15/100 (12 `ENOENT open`, 3 `ENOENT scandir`: absent at the read, back by the `lstat`) | 64/100 | n/a | | (c) residual | **head** | **0/100** | 57/100 (accepted, see notes) | absent at the read skipped in 15 runs | Every head run in (a) and (b) read exactly 2690 files. Probe (c) is the only one that exercises the two new legs on head, since (b) is never listed. Cleanup: `ls` answers `No such file or directory` for all three probe paths, `git status --porcelain` is empty, and `git status --ignored` shows no probe leftovers. ### Still throws: one-time proof, not a permanent test The head's walk, extracted verbatim, was run over a throwaway tree in the scratchpad. Injected error codes stand in where running as root cannot produce them: **10/10 ok**. - The control collects `src/a.ts` and never anything under `tmp/`. - A missing ROOT throws `ENOENT`. - A dangling symlink throws `ENOENT` at the stat, at the read, and as a dangling directory link at the `readdirSync`. - `EACCES` throws at the stat, at a listed `readdirSync` and at the read. - `ENOTDIR` throws at a listed `readdirSync`. - A plain file removed between collection and read is skipped (`null`). ### Tests (head `5327f65679`, one `os-verify-lock` hold, VERDICT command-exit 0) - `pnpm --filter '@objectstack/lint...' build` exit 0. - The touched file alone: `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 --reporter=verbose src/lint-startup-registry-verdict.corpus.test.ts` gives `Test Files 1 passed (1)` and `Tests 5 passed (5)`, with all five cases listed. `packages/lint` declares no vitest projects. - The whole package: `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` gives `Test Files 111 passed (111)` and `Tests 4273 passed (4273)`. - `pnpm --filter @objectstack/lint typecheck` exit 0 (`check:test-typecheck: OK`). ### Gates `dispatch-gates --commands` at `5327f65679` derives the same 52: - **49 exit 0, `check:cross-package-test-inputs` included.** Its line: "13 walked root(s) judged, 3 on ACCEPTED_WALK_RADII". - 3 are NOT MEASURED, exit 3 with PREREQUISITE NOT MET: - `check:dual-build-cjs-loads` and `check:type-check-debt` need the full packages build; - `check:lean-entry-closure` needs the objectql closure, which was not built in this worktree. It was green in stage 1. - `--ran`: "52 derived famil(ies) accounted for — 49 run, 3 NOT-MEASURED". - CI on `5327f65679`: 30 success, 4 skipped, 0 failures. `Lint & Repo Gates`, `Test Core`, `TypeScript Type Check`, `Build Core`, `Dogfood Regression Gate` and `Governed Surface Queue Guard` all passed. ## Earlier rounds (superseded, kept for the record) - **Round 1** (`5859311330`, ruling B, heads `f3b4bc2738` and `b6fea03599`) built the corpus from `git ls-files`. Both probes were 0/100 and the count was 2690. But `check:cross-package-test-inputs` owes this test's ACCEPTED_WALK_RADII entry only to a `readdirSync` / `opendirSync` descent, and a git-listed corpus has none, so the gate went red. Round 2 restores the walk. - **Stage 1** (`606510b23c`) added `statListedEntry` (the rule `scripts/check-error-status-conformance.mjs` pins, landed with PR #20267, for #20225). The tsup probe went 23/100 on base to 0/100. It left the `readdirSync` and `readFileSync` legs open, which round 2 closes. ## No changeset `@objectstack/lint` publishes `["dist","README.md","CHANGELOG.md"]`. Measured at stage 1: the change had 0 hits there, against 4 files for the positive control `findStartupRegistryVerdicts`. Every round touches only a `*.test.ts`. The PR ships nothing, and it carries `skip-changeset`. ## Acceptance notes **Accepted residual (seat `5859739814`):** an untracked, NOT-ignored scratch directory under `packages/` would still be walked. Probe (c) on head: 0/100 throws, but the transient `.ts` entered the audited corpus in 57/100 runs. **Accepted**, because no such producer exists today. The in-tree scratch roots measured are `packages/cli/tmp` (now skipped), `packages/spec/.examples-build` (`check:skill-examples`, lint job only) and `node_modules/.…` roots (skipped). All of them are gitignored. **Family census:** `git grep -l readdirSync -- 'packages/**/*.test.ts' | xargs grep -l statSync` at base `3f86dc52f2` returns **23 files**. The card listed 9 at `4d7e740d`, and the dev had counted 12. Question A: does it walk a tree a concurrent build writes into? Question B: does a bare `statSync`/`lstatSync` run on a listed entry before a filter would drop it? Fixed only where both answers are yes. Where the transients land, read from source: bundle-require 5.1.0's `defaultGetOutputFile` writes `tsup.config.bundled_ID.mjs` next to the config it loads. That is the package root for the 21 packages with their own `tsup.config.ts`, and the repo root for the 46 that build with `--config` pointed at the root config. No `tsup`/`vite`/`vitest` config lives under any `src/`. Vite 8.0.16 writes its bundled config under `node_modules/.vite-temp/`. `packages/cli` builds with `tsc` into `dist/`. `@objectstack/spec`'s generators write `json-schema/` and its sibling artifact dirs at the package root, never into `src/`. 1. `packages/cli/src/commands/migrate/multi-value-columns.no-auto-run.test.ts` (`everyTsFile`): A no, it walks `packages/cli/src` only (B yes). Left alone. 2. `packages/cli/src/utils/console-route-ledger.conformance.test.ts` (`packageSourceFiles`): A no, `packages/cli/src` only. Left alone. 3. `packages/cli/src/utils/port-contract-single-source.test.ts` (`everySourceFile`): A no, `packages/cli/src` only. Left alone. 4. `packages/cli/test/diff-usage-error-stream.e2e.test.ts` (`commandFiles`): A no, `packages/cli/src/commands` only. Left alone. 5. `packages/cli/test/json-stdout-purity.e2e.test.ts` (`commandFiles`): A no, `packages/cli/src/commands` only. Left alone. 6. `packages/cli/test/resolve-glob-lazy-walk.test.ts`: no walker of its own. It spies on `fs.readdirSync`/`fs.statSync` to pin a product walker over a fixture. Left alone. 7. `packages/client/src/envelope-caller-census.test.ts` (`walk`): A yes, it walks the repo root. B no, because the stat sits in a catch-all `continue` and is not bare. Left alone. 8. `packages/core/src/security/authz-store-unavailable.test.ts` (`walk`): A yes, `packages/`. B no, because it uses `withFileTypes` and stats only a symlink entry. Left alone. 9. `packages/core/src/security/operation-private-keys.pin.test.ts`: no walker. The hit is prose explaining its move to git's file list. Left alone. 10. `packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts`: lists and stats a temp directory it created itself, a fixed fixture. Left alone. 11. `packages/lint/src/lint-startup-registry-verdict.corpus.test.ts` (`collectSourceFiles`): A yes, `packages/`. B yes, a bare `statSync` before the `.ts` filter. **Fixed here**, with the rule on all three legs plus `tmp` in `SKIP_DIRS`. 12. `packages/metadata/src/metadata-route-ledger.conformance.test.ts` (`packageSourceFiles`): A no, `packages/metadata/src` only. Left alone. 13. `packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts` (`walkObjectFiles`): A yes, `packages/`. B no, catch-all. Left alone. 14. `packages/plugins/organizations/src/no-framework-dependents.pin.test.ts` (`workspacePackageManifests`): A no. It lists `packages/` and the eight group dirs one level deep, whose entries are package directories. The transients land inside a package root or the repo root, which it never lists. Left alone. 15. `packages/plugins/organizations/src/open-only-wall-acceptance.test.ts` (`workspaceManifests`): A no, the same shape as 14. Left alone. 16. `packages/plugins/plugin-auth/src/managed-extension-fields.test.ts` (`walkObjectFiles`): A yes, `packages/`. B no, catch-all. Left alone. 17. `packages/plugins/plugin-auth/src/platform-owner-email-reader-census.pin.test.ts` (`sourceFiles`): A no, `plugin-auth/src` and `plugin-security/src` only. Left alone. 18. `packages/plugins/plugin-email/src/transports/smtp-port-contract.test.ts` (`everySourceFile`): A no, `plugin-email/src` only. Left alone. 19. `packages/qa/downstream-contract/test/source-resolution.pin.test.ts` (`literalSpecImports`): A no. It walks its own `src/` and `test/` in a private package with no build script. Left alone. 20. `packages/spec/scripts/category-title.test.ts` (`moduleDirsOnDisk`, and a docs `walk`): A no. It lists the top level of `packages/spec/src`, and the docs walk uses `withFileTypes` with no stat. Left alone. 21. `packages/spec/src/data/api-methods-batch-conformance.test.ts` (`walkObjectFiles`): A yes, `packages/`. B no, catch-all. Left alone. 22. `packages/spec/src/shared/retired-key-migrate-sentence.test.ts` (`walk`, `walkMarkdown`): A no. It walks `spec/src`, `lint/src` and the published `skills/` tree (with `lstat`), and no build writes into any of them. Left alone. 23. `packages/spec/src/system/constants/platform-object-names.test.ts` (`walk`): A yes, `packages/`. B no, catch-all. Left alone. Totals: 23 hits. 1 answers yes to both and is fixed here. 22 do not and are untouched. No file in `packages/cli`, `packages/core`, `packages/plugins/plugin-email` or `packages/spec` is touched. **Findings, as the seat disposed of them:** - The `packages/cli/tmp` fixture overlap: **folded in** and closed by `tmp` in `SKIP_DIRS` (probe (b)). - The five catch-all walkers (7, 13, 16, 21, 23): **dropped — no measured reach**. - `withFileTypes` on a filesystem reporting `DT_UNKNOWN` (8): **dropped — no measured reach**. - The gate's blind spot for git-listed descents (round 1): **dropped — no measured victim**. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53a5fef commit 10ea9eb

1 file changed

Lines changed: 128 additions & 14 deletions

File tree

‎packages/lint/src/lint-startup-registry-verdict.corpus.test.ts‎

Lines changed: 128 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
// shrink the sweep while the file count stayed comfortably non-zero, and the
2020
// test would report a clean audit over source it never opened — the exact
2121
// shape the rule itself is about, turned on the rule (#4930). So the root is
22-
// resolved up front and the walk carries no `catch`.
22+
// resolved up front, and the walk tolerates exactly one error: an entry its
23+
// own listing named that is gone when it is read, judged at the stat
24+
// (`statListedEntry`), at a listed directory's `readdirSync`
25+
// (`readListedDirectory`) and at the sweep's `readFileSync`
26+
// (`readCollectedFile`). Every other error is rethrown.
2327
// 2. **A rule that matches nothing.** A ratchet that has only ever been green
2428
// cannot be told apart from a dead one (#4690), and this one has been green
2529
// from its first commit. `the sweep can still fire` therefore pushes a
@@ -37,7 +41,7 @@
3741
// gets (2)'s treatment rather than a comment: it is a pure function, and
3842
// `the staleness comparison can still fire` pushes a known pair through the
3943
// SAME function the real case calls.
40-
import { readdirSync, readFileSync, statSync } from 'node:fs';
44+
import { lstatSync, readdirSync, readFileSync, statSync, type Stats } from 'node:fs';
4145
import { dirname, join, relative } from 'node:path';
4246
import { fileURLToPath } from 'node:url';
4347
import { beforeAll, describe, expect, it } from 'vitest';
@@ -66,20 +70,129 @@ const packagesDir = join(repoRoot, 'packages');
6670
*/
6771
const LEDGER: Readonly<Record<string, string>> = {};
6872

69-
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']);
73+
/**
74+
* Directory names the walk never enters. `tmp` is gitignored scratch (the root
75+
* `.gitignore` ignores `tmp/`, and no tracked path under `packages/` sits below
76+
* one): `packages/cli` tests create and delete `.ts` fixtures under
77+
* `packages/cli/tmp/`, which are not source and must not be audited as if they
78+
* were.
79+
*/
80+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next', 'tmp']);
7081

7182
/**
72-
* Every auditable `.ts` under `dir`.
83+
* Whether `err`, thrown by a read of `path`, means the entry was ABSENT at that
84+
* read after the walk's own listing named it: the read answered `ENOENT`, and an
85+
* `lstat` of `path` does not find a symlink there. The rule is the one the
86+
* error-status walker pins (`statListedEntry` in `check-error-status-conformance.mjs`
87+
* under `scripts/`), carried from the stat to the two reads that follow it.
7388
*
74-
* No `catch`: an error during the walk means the corpus was only partly read,
75-
* which must not be reported as a clean audit.
89+
* Why it exists: this suite runs inside the same turbo run as package builds and
90+
* other packages' tests, and both leave transient entries under `packages/`.
91+
* tsup (through bundle-require) writes `tsup.config.bundled_ID.mjs` beside a
92+
* package's config, imports it and unlinks it. Any entry a listing names can be
93+
* gone by the stat, by its own `readdirSync` or by the sweep's `readFileSync`,
94+
* and a bare read then threw `ENOENT` and failed the whole corpus.
95+
*
96+
* `true` when the `lstat` answers `ENOENT` (the entry vanished), and also when
97+
* it finds a non-symlink back under the same name: the read still found nothing,
98+
* and reading again would be a retry. `false` for everything else, which the
99+
* caller rethrows:
100+
* - any code but `ENOENT`, `ENOTDIR` and `EACCES` included;
101+
* - a dangling symlink (the read answers `ENOENT`, `lstat` finds the link):
102+
* the entry exists, and skipping it would shrink the corpus.
103+
* A missing ROOT never reaches here: no listing named it, and the root's own
104+
* `readdirSync` is bare. Nothing is matched by name. ⛔ Never a retry.
105+
*/
106+
function absentAtRead(err: unknown, path: string): boolean {
107+
if ((err as NodeJS.ErrnoException | undefined)?.code !== 'ENOENT') return false;
108+
try {
109+
return !lstatSync(path).isSymbolicLink();
110+
} catch (again) {
111+
if ((again as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return true;
112+
throw again;
113+
}
114+
}
115+
116+
/**
117+
* `statSync(path)` for an entry the walk's own listing just named, or `null`
118+
* when that entry VANISHED since. It applies `absentAtRead`'s rule inline,
119+
* because this leg alone keeps the `lstat` result: an entry absent at the stat
120+
* and back under the same name by the `lstat` is audited as present, since for
121+
* a non-symlink `lstat` IS its `stat`. A dangling symlink still throws the
122+
* stat's own error.
76123
*/
77-
function collectSourceFiles(dir: string, out: string[] = []): string[] {
78-
for (const entry of readdirSync(dir)) {
124+
function statListedEntry(path: string): Stats | null {
125+
try {
126+
return statSync(path);
127+
} catch (err) {
128+
if ((err as NodeJS.ErrnoException | undefined)?.code !== 'ENOENT') throw err;
129+
let entry: Stats;
130+
try {
131+
entry = lstatSync(path);
132+
} catch (again) {
133+
if ((again as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null; // vanished
134+
throw again;
135+
}
136+
// Present after all. A symlink here is dangling: throw the original error.
137+
if (entry.isSymbolicLink()) throw err;
138+
// Absent at the stat, back under the same name: for a non-symlink, `lstat`
139+
// IS its `stat`, so it is audited as present.
140+
return entry;
141+
}
142+
}
143+
144+
/**
145+
* `readdirSync(dir)` for a directory the walk's own listing named, or `null`
146+
* when it was ABSENT at this read (`absentAtRead`). A listed directory
147+
* replaced by a file answers `ENOTDIR`, which throws.
148+
*/
149+
function readListedDirectory(dir: string): string[] | null {
150+
try {
151+
return readdirSync(dir);
152+
} catch (err) {
153+
if (absentAtRead(err, dir)) return null;
154+
throw err;
155+
}
156+
}
157+
158+
/**
159+
* `readFileSync(file)` for a file the walk collected, or `null` when it was
160+
* ABSENT at the sweep's read (`absentAtRead`).
161+
*/
162+
function readCollectedFile(file: string): string | null {
163+
try {
164+
return readFileSync(file, 'utf8');
165+
} catch (err) {
166+
if (absentAtRead(err, file)) return null;
167+
throw err;
168+
}
169+
}
170+
171+
/**
172+
* Every auditable `.ts` under `root`.
173+
*
174+
* No `catch` of its own: an error during the walk means the corpus was only
175+
* partly read, which must not be reported as a clean audit. The one tolerated
176+
* error, an entry a listing named that is gone when it is read, is decided by
177+
* `statListedEntry` and `absentAtRead` above. The ROOT's own listing is bare,
178+
* so a missing root throws.
179+
*/
180+
function collectSourceFiles(root: string): string[] {
181+
const out: string[] = [];
182+
collectListedEntries(root, readdirSync(root), out);
183+
return out;
184+
}
185+
186+
function collectListedEntries(dir: string, entries: readonly string[], out: string[]): void {
187+
for (const entry of entries) {
79188
if (SKIP_DIRS.has(entry)) continue;
80189
const full = join(dir, entry);
81-
if (statSync(full).isDirectory()) collectSourceFiles(full, out);
82-
else if (
190+
const stats = statListedEntry(full);
191+
if (stats === null) continue;
192+
if (stats.isDirectory()) {
193+
const children = readListedDirectory(full);
194+
if (children !== null) collectListedEntries(full, children, out);
195+
} else if (
83196
entry.endsWith('.ts') &&
84197
!entry.endsWith('.d.ts') &&
85198
!entry.includes('.test.') &&
@@ -89,7 +202,6 @@ function collectSourceFiles(dir: string, out: string[] = []): string[] {
89202
out.push(full);
90203
}
91204
}
92-
return out;
93205
}
94206

95207
/** The sweep, as one function, so the corpus and the non-vacuity case share it. */
@@ -207,9 +319,11 @@ describe('startup open-vocabulary verdicts across packages/ (#4776)', () => {
207319
let sweepResult: readonly StartupRegistryVerdictFinding[] | undefined;
208320

209321
beforeAll(() => {
210-
const findings = sweep(
211-
files.map((file) => ({ file: relative(repoRoot, file), source: readFileSync(file, 'utf8') })),
212-
).map((finding) => Object.freeze(finding));
322+
const sources = files.flatMap((file) => {
323+
const source = readCollectedFile(file);
324+
return source === null ? [] : [{ file: relative(repoRoot, file), source }];
325+
});
326+
const findings = sweep(sources).map((finding) => Object.freeze(finding));
213327
sweepResult = Object.freeze(findings);
214328
}, CORPUS_SWEEP_BUDGET_MS);
215329

0 commit comments

Comments
 (0)