diff --git a/.changeset/issue-8704-census-matcher-ast.md b/.changeset/issue-8704-census-matcher-ast.md new file mode 100644 index 0000000000..53607edc43 --- /dev/null +++ b/.changeset/issue-8704-census-matcher-ast.md @@ -0,0 +1,12 @@ +--- +--- + +Repo script and its pin test only. `scripts/census-recorder-wait-shape.mjs` gains an AST +matcher (objectui#8704): recorder identity resolved over bindings and aliases instead of +name spelling, forward windows scoped to the enclosing test body in statements, and every +occurrence classified read / write / declaration. The original regex census is kept behind +`--matcher=regex` so objectui#8690's and objectui#8703's published numbers stay +reproducible. objectui#8703's five fixtures are committed as the script's test suite, with +a sixth that must stay flagged. The header's "no count here is a corpus fact" caveat stays, +with its residuals restated. The census is still not wired into CI. No published behaviour +changes. diff --git a/scripts/__tests__/census-recorder-wait-shape.test.ts b/scripts/__tests__/census-recorder-wait-shape.test.ts new file mode 100644 index 0000000000..883881797c --- /dev/null +++ b/scripts/__tests__/census-recorder-wait-shape.test.ts @@ -0,0 +1,152 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Pins for `scripts/census-recorder-wait-shape.mjs` (objectui#8704). + * + * The six fixtures next to this file are objectui#8703's five throwaway cases, + * kept, plus the sixth objectui#8704 asked for. Each one was FIRST observed + * giving the wrong answer on the pre-repair script — that is why the regex + * block below exists at all. Both matchers are asserted over the SAME six + * files, so the repair is pinned as a direction and not just as a state: + * + * fixture regex ident regex path AST + * f1 member push / member read miss FLAG FLAG + * f2 member push / bare read FLAG miss FLAG + * f3 bare push / member read miss miss FLAG + * f4 runaway window (D1 + D2) 6 wrong 6 wrong none + * f5 hazard behind one await miss miss FLAG + * f6 absence read (the control) FLAG FLAG FLAG + * + * ⛔ This suite runs the matcher over the FIXTURES ONLY, never over the + * corpus. The census stays out of CI (objectui#8703's fence, restated in the + * script header): nothing here depends on what the repository reads. + * + * ⭐ f6 is the anti-caricature control. "Flag nothing" is strictly worse than + * the bug this card repairs, and it passes every other assertion in this file. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { analyzeAst, analyzeRegex } from '../census-recorder-wait-shape.mjs'; + +const DIR = join(__dirname, 'fixtures', 'census-recorder-wait-shape'); + +const F1 = 'f1-member-push-member-read.fixture.ts'; +const F2 = 'f2-member-push-bare-read.fixture.ts'; +const F3 = 'f3-bare-push-member-read.fixture.ts'; +const F4 = 'f4-window-crosses-test.fixture.ts'; +const F5 = 'f5-window-truncated.fixture.ts'; +const F6 = 'f6-absence-read-must-stay-flagged.fixture.ts'; +const ALL = [F1, F2, F3, F4, F5, F6]; + +const paths = ALL.map((f) => join(DIR, f)); + +interface Flag { + file: string; + line: number; + waitLine: number; + recorder: string; + waitSet: string[]; +} + +/** The source line a flag points at, trimmed — the only address that survives an edit. */ +const lineAt = (file: string, line: number) => + readFileSync(file, 'utf8').split('\n')[line - 1]!.trim(); + +/** Strict flags for one fixture, as `` entries. */ +const strictLines = (flags: Flag[], fixture: string) => + flags + .filter((f) => f.waitSet.length > 0 && basename(f.file) === fixture) + .sort((a, b) => a.line - b.line) + .map((f) => lineAt(f.file, f.line)); + +const ast = () => analyzeAst(paths) as Flag[]; +const regex = (mode: 'ident' | 'path') => analyzeRegex(paths, mode) as Flag[]; + +describe('the pre-repair regex matcher — objectui#8704\'s three defects, executable', () => { + it('D1+D2: flags DECLARATIONS in the next test, and a reset in this one', () => { + // Every one of these was measured on the pre-repair script before the AST + // matcher existed. `scratch.length = 0` is D2 with the window rule already + // correct (same test); `expect(shared[0])` is D1 with the occurrence rule + // already correct (a genuine read, wrong test). + const wrong = [ + 'const second: number[] = [];', + 'const third: number[] = [];', + 'const scratch: number[] = [];', + 'scratch.length = 0;', + 'const shared: number[] = [];', + 'expect(shared[0]).toBe(4);', + ]; + expect(strictLines(regex('path'), F4)).toEqual(wrong); + expect(strictLines(regex('ident'), F4)).toEqual(wrong); + }); + + it('D1, the mirror: a real hazard one ordinary `await` further on draws ZERO', () => { + expect(strictLines(regex('path'), F5)).toEqual([]); + expect(strictLines(regex('ident'), F5)).toEqual([]); + }); + + it('M2: a recorder pushed bare and read through its host is invisible to BOTH modes', () => { + expect(strictLines(regex('path'), F3)).toEqual([]); + expect(strictLines(regex('ident'), F3)).toEqual([]); + }); + + it('M1: the two modes are incomparable — each sees a shape the other cannot', () => { + expect(strictLines(regex('path'), F1)).toEqual(['expect(server.savedOpts[0]).toMatchObject({ mode: \'draft\' });']); + expect(strictLines(regex('ident'), F1)).toEqual([]); + + expect(strictLines(regex('ident'), F2)).toEqual(['expect(inits[0]).toBe(1);']); + expect(strictLines(regex('path'), F2)).toEqual([]); + }); + + it('gets f6 right — so the repair has something it must NOT break', () => { + expect(strictLines(regex('path'), F6)).toEqual(['expect(deletes).toEqual([]);']); + expect(strictLines(regex('ident'), F6)).toEqual(['expect(deletes).toEqual([]);']); + }); +}); + +describe('the AST matcher — identity, test-scoped windows, read/write/declare', () => { + it('D1+D2 repaired: the runaway window flags NOTHING in f4', () => { + expect(strictLines(ast(), F4)).toEqual([]); + }); + + it('D1 mirror repaired: the hazard behind an ordinary `await` is found', () => { + // A bare `await` settles nothing, so it must not close the window. + expect(strictLines(ast(), F5)).toEqual(['expect(payloads[0]).toBe(2);']); + }); + + it('M2 repaired: a host member and the bare array it holds are ONE recorder', () => { + expect(strictLines(ast(), F3)).toEqual(['expect(host.inits[0]).toBe(1);']); + }); + + it('M1 dissolved: both spellings flag, with no mode to choose', () => { + expect(strictLines(ast(), F1)).toEqual(['expect(server.savedOpts[0]).toMatchObject({ mode: \'draft\' });']); + expect(strictLines(ast(), F2)).toEqual(['expect(inits[0]).toBe(1);']); + }); + + it('⭐ still flags the absence read objectui#8690 repaired — NOT a matcher that reports nothing', () => { + // The caricature this card names: an implementation strictly worse than the + // bug ("flag nothing") passes f4 and every "must be empty" case above. It + // fails here, and here is the only place it can fail. + expect(strictLines(ast(), F6)).toEqual(['expect(deletes).toEqual([]);']); + expect(ast().filter((f) => f.waitSet.length > 0)).toHaveLength(5); + }); + + it('names the recorder as the READ site spells it, not as its push site does', () => { + // Two distinct arrays can share a push-site spelling; printing that made a + // correct flag read as "waits [calls] reads calls" on the real corpus. + const f3 = ast().find((f) => basename(f.file) === F3)!; + expect({ waits: f3.waitSet, reads: f3.recorder }).toEqual({ + waits: ['host.calls'], + reads: 'host.inits', + }); + }); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/f1-member-push-member-read.fixture.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/f1-member-push-member-read.fixture.ts new file mode 100644 index 0000000000..7495f970c1 --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/f1-member-push-member-read.fixture.ts @@ -0,0 +1,20 @@ +/** + * f1 — pushed and read as the SAME member path. + * + * Pre-repair: flagged by `--recorder-match=path` only; invisible to `ident`, + * whose name regex has a lookbehind that forbids a preceding `.`. + * Repaired: flagged. It is a genuine cross-recorder read. + */ +import { it, expect, waitFor } from './harness'; + +it('merges the slice on save', async () => { + const server = { saved: [] as string[], savedOpts: [] as Record[] }; + const save = (name: string, opts: Record) => { + server.saved.push(name); + server.savedOpts.push(opts); + }; + + save('app.a', { mode: 'draft' }); + await waitFor(() => expect(server.saved.length).toBe(1)); + expect(server.savedOpts[0]).toMatchObject({ mode: 'draft' }); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/f2-member-push-bare-read.fixture.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/f2-member-push-bare-read.fixture.ts new file mode 100644 index 0000000000..fa644dd552 --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/f2-member-push-bare-read.fixture.ts @@ -0,0 +1,22 @@ +/** + * f2 — pushed as a member, waited and read under bare aliases. + * + * Pre-repair: flagged by `--recorder-match=ident` only; invisible to `path`, + * which knows the recorders as `host.calls` / `host.inits` and never matches + * the bare spellings. + * Repaired: flagged. The alias and the member path are the same array. + */ +import { it, expect, waitFor } from './harness'; + +it('records the init alongside the call', async () => { + const host = { calls: [] as string[], inits: [] as number[] }; + const record = (n: number) => { + host.calls.push('c'); + host.inits.push(n); + }; + + record(1); + const { calls, inits } = host; + await waitFor(() => expect(calls.length).toBe(1)); + expect(inits[0]).toBe(1); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/f3-bare-push-member-read.fixture.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/f3-bare-push-member-read.fixture.ts new file mode 100644 index 0000000000..2edf933d50 --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/f3-bare-push-member-read.fixture.ts @@ -0,0 +1,23 @@ +/** + * f3 — pushed bare, waited and read through the host that holds the SAME array. + * + * Pre-repair: ZERO flags in BOTH modes — the shared blind spot objectui#8703 + * corrected the header about. The lookbehind blocks the dotted read whichever + * way the push site was spelled. + * Repaired: flagged. `host.inits` and `inits` resolve to one binding. + */ +import { it, expect, waitFor } from './harness'; + +it('records the init alongside the call', async () => { + const calls: string[] = []; + const inits: number[] = []; + const host = { calls, inits }; + const record = (n: number) => { + calls.push('c'); + inits.push(n); + }; + + record(1); + await waitFor(() => expect(host.calls.length).toBe(1)); + expect(host.inits[0]).toBe(1); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/f4-window-crosses-test.fixture.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/f4-window-crosses-test.fixture.ts new file mode 100644 index 0000000000..2aebf934eb --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/f4-window-crosses-test.fixture.ts @@ -0,0 +1,57 @@ +/** + * f4 — the forward window is not scoped to the enclosing test (D1), and any + * textual occurrence counts as a read (D2). + * + * Cases `a` / `b` are objectui#8704's own reproduction, verbatim: the wait is + * the last `await` of test `a`, so its window runs on into test `b`, whose + * opening line DECLARES a recorder. Both defects fire at once there. + * + * `c` and `d` / `e` were added here to separate them, because the fixed + * versions mask each other on `a` / `b` alone: + * c — D2 with the window rule already correct: a reset and a push, in + * the SAME test, after the wait. Nothing crosses a test boundary. + * d / e — D1 with the occurrence rule already correct: the next test's + * recorder is DECLARED before both tests, so the first occurrence + * inside the runaway window is a genuine READ. + * + * Pre-repair: flagged in both modes. + * Repaired: ZERO flags. + */ +import { describe, it, expect, waitFor } from './harness'; + +it('a — its wait is the last await of this test', async () => { + const first: number[] = []; + first.push(1); + await waitFor(() => expect(first.length).toBe(1)); + expect(first[0]).toBe(1); +}); + +it('b — a different test, with its own recorder', async () => { + const second: number[] = []; + second.push(2); + await waitFor(() => expect(second.length).toBe(1)); +}); + +it('c — a reset and a push after the wait, inside the SAME test', async () => { + const third: number[] = []; + const scratch: number[] = []; + third.push(3); + await waitFor(() => expect(third.length).toBe(1)); + scratch.length = 0; + scratch.push(4); +}); + +describe('d/e — the runaway window reaches a real read in the next test', () => { + const shared: number[] = []; + + it('d — its wait is the last await of this test', async () => { + const anchor: number[] = []; + anchor.push(1); + await waitFor(() => expect(anchor.length).toBe(1)); + }); + + it('e — a different test reads the recorder declared above both', () => { + shared.push(4); + expect(shared[0]).toBe(4); + }); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/f5-window-truncated.fixture.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/f5-window-truncated.fixture.ts new file mode 100644 index 0000000000..2bd769fc30 --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/f5-window-truncated.fixture.ts @@ -0,0 +1,24 @@ +/** + * f5 — a genuine cross-recorder hazard sitting one `await` further on. + * + * Pre-repair: ZERO flags in BOTH modes. The forward window ends at the next + * textual `await`, so `await Promise.resolve()` truncates it before the read. + * That is the MIRROR of f4: the same rule that over-reports also goes blind. + * Repaired: flagged. A bare `await` settles nothing, so it does not close the + * window; only another awaited settling anchor does. + */ +import { it, expect, waitFor } from './harness'; + +it('reads the payload the wait never mentioned', async () => { + const arrivals: string[] = []; + const payloads: number[] = []; + const receive = (tag: string, n: number) => { + arrivals.push(tag); + setTimeout(() => payloads.push(n), 0); + }; + + receive('a', 2); + await waitFor(() => expect(arrivals.length).toBe(1)); + await Promise.resolve(); + expect(payloads[0]).toBe(2); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/f6-absence-read-must-stay-flagged.fixture.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/f6-absence-read-must-stay-flagged.fixture.ts new file mode 100644 index 0000000000..fc59a7c432 --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/f6-absence-read-must-stay-flagged.fixture.ts @@ -0,0 +1,30 @@ +/** + * f6 — the shape objectui#8690 audited and PR #8702 repaired + * (`MetadataObjectsPage.lookupKeying`): wait on the write log, then assert the + * delete log is EMPTY. An absence dated to the first write cannot see a delete + * issued after it. + * + * Pre-repair: flagged (it was one of the nine). + * Repaired: STILL flagged. + * + * ⭐ This fixture is the anti-caricature control. A matcher that "fixes" + * over-reporting by reporting nothing passes f4 and fails here. + */ +import { it, expect, waitFor } from './harness'; + +it('issues no delete for a rename', async () => { + const puts: string[] = []; + const deletes: string[] = []; + const client = { + put: (name: string) => { + puts.push(name); + }, + reset: (name: string) => { + deletes.push(name); + }, + }; + + client.put('contact'); + await waitFor(() => expect(puts.length).toBe(1)); + expect(deletes).toEqual([]); +}); diff --git a/scripts/__tests__/fixtures/census-recorder-wait-shape/harness.ts b/scripts/__tests__/fixtures/census-recorder-wait-shape/harness.ts new file mode 100644 index 0000000000..ccb3fb1023 --- /dev/null +++ b/scripts/__tests__/fixtures/census-recorder-wait-shape/harness.ts @@ -0,0 +1,30 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Local stand-ins for `vitest` / `@testing-library/react`, so the fixtures next + * to this file are self-contained TypeScript. + * + * They are NOT executed. `tsconfig.scripts.json` compiles every `.ts` under + * `scripts/`, so the fixtures have to type-check; importing the real `vitest` + * and `@testing-library/react` would drag DOM lib types into a project whose + * `lib` is `ES2022` + `types: ["node"]`. Declaring the three names locally + * keeps the fixtures compiling while leaving their SOURCE SHAPE — which is the + * only thing the census reads — identical to a real test file. + */ + +export declare function describe(name: string, fn: () => void): void; +export declare function it(name: string, fn: () => Promise | void): void; +export declare function waitFor(fn: () => void): Promise; + +export interface Matchers { + toBe(expected: unknown): void; + toEqual(expected: unknown): void; + toMatchObject(expected: object): void; +} +export declare function expect(actual: unknown): Matchers; diff --git a/scripts/census-recorder-wait-shape.mjs b/scripts/census-recorder-wait-shape.mjs index 3732191fb7..c133705f8e 100644 --- a/scripts/census-recorder-wait-shape.mjs +++ b/scripts/census-recorder-wait-shape.mjs @@ -1,4 +1,12 @@ #!/usr/bin/env node +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + /** * Census: `await waitFor(...)` keyed on ONE recorder array, followed by a read * of a DIFFERENT recorder array, with nothing establishing the second was @@ -10,158 +18,881 @@ * not a defect: objectui#8690 read all nine of its strict-shape flags and found * one worth repairing. ⛔ Never batch-repair a flag list. * - * Algorithm (the card's four steps): - * 1. per file, recorders = every identifier that is the target of `.push(` - * 2. per `await waitFor(`, balance parens; recorders named inside = WAIT SET - * 3. scan forward to the next `await` (or EOF), collecting recorders READ - * (a recorder's own `.push(` line does not count as a read) - * 4. flag any read of a recorder the wait did not name - * - * Two recorder-matching modes, because the choice moves the numbers: - * --recorder-match=ident bare identifiers (`calls`), receiver ignored - * --recorder-match=path dotted paths (`server.savedOpts`) — the default + * --------------------------------------------------------------------------- + * TWO MATCHERS LIVE HERE + * --------------------------------------------------------------------------- * - * Measured on da5e4f69e, 2776 tracked `*.test.ts`/`*.test.tsx` files: - * ident: 159 flags, 15 strict in 10 files - * path : 167 flags, 18 strict in 12 files - * objectui#8690 reported 160 flags and 9 strict, so this re-derivation is one - * flag off its total and wider in the strict bucket — near enough to be the - * same instrument, ⛔ not near enough to quote its numbers as reproduced. + * --matcher=ast (default) binding identity, test-scoped windows, and + * read / write / declaration classification. objectui#8704. + * --matcher=regex the original objectui#8690 census, verbatim, with its + * `--recorder-match=ident|path` modes. Kept so the numbers + * objectui#8690 and objectui#8703 published stay + * REPRODUCIBLE from this file instead of being claims about + * a deleted script. ⛔ Do not read anything it prints as a + * corpus fact — objectui#8703 measured why, and the header + * section below says exactly what it gets wrong. * * --------------------------------------------------------------------------- - * WHAT THE TWO MODES DISAGREE ABOUT — and why no count printed here is a - * corpus fact (objectui#8703). Every claim below was FORCED on constructed - * fixtures, not read off the regexes; the fixtures are in that card's PR. + * WHAT THE REGEX MATCHER GETS WRONG (objectui#8703, measured on fixtures) * --------------------------------------------------------------------------- * - * A recorder's identity is its SPELLING at the `.push(` site, matched + * A recorder's identity there is its SPELLING at the `.push(` site, matched * textually at the read site by a name regex whose lookbehind forbids a - * preceding `.`. Three consequences, each measured: - * - * 1. The modes are INCOMPARABLE BY CONSTRUCTION — each sees a shape the - * other cannot: - * `path` only — pushed and read as the same member path - * (`server.saved.push(x)` … `server.savedOpts[0]`). - * `ident` only — pushed as a member, read under a bare alias - * (`host.calls.push(x)`; `const { calls } = host` … `calls.length`). - * ⛔ The first version of this header also said `path` misses a recorder - * pushed bare and read as `host.inits[0]`. That is WRONG: the lookbehind - * blocks a dotted read in BOTH modes, so that shape is a SHARED blind - * spot, and no mode of this instrument can see it. - * 2. On THIS corpus the buckets are nevertheless NESTED: at da5e4f69e - * ident's 15 ⊂ path's 18, and ident-only is EMPTY (the three extra are - * the `server.saved` / `server.savedOpts` sites in app-shell). So "their - * strict buckets do not contain each other" holds in principle and is - * FALSE as a measurement of this tree — the union is just `path`'s bucket. - * 3. ⭐ The mode choice is NOT the largest source of movement. Two rules that - * are identical in both modes dominate it: - * D1 the forward window ends at the next textual `await` IN THE FILE, - * not at the end of the enclosing test. A wait that is the last - * `await` of its test gets a window that runs on into the NEXT test - * — whose opening lines are exactly where recorders get declared. - * D2 ANY textual occurrence counts as a "read"; only `X.push(` is - * excluded. A declaration (`const blobs: Blob[] = []`), a - * destructuring (`const { requested } = …`), a reset - * (`gridSchemas.length = 0`), even a parameter named `log`, all - * register as reads. - * Measured: of the 18 strict flags at da5e4f69e, SEVEN point at something - * that is not a read at all. objectui#8703 read all seven of the sites no - * one had audited and repaired NONE — six were D1+D2 artefacts, and the - * seventh (`PermissionMatrixEditor.scope.test.tsx:177`) was forced and - * measured SOUND. The same truncation also LOSES real hazards: a genuine - * cross-recorder read one `await` further on is flagged by neither mode. - * - * ⇒ ⛔ NO COUNT THIS SCRIPT PRINTS IS A CORPUS FACT. objectui#8690's 9, this - * file's 15/18, and whatever a later run prints are all readings of - * (matcher mode × window rule × occurrence class × tree), and about a third - * of the strict bucket is not the shape it claims to be. Quote a number as - * "sites this instrument points at", never as "sites of this shape". - * A matcher that could be quoted would have to resolve recorder IDENTITY - * (binding/alias resolution over an AST, not name spelling), scope the - * window to the enclosing test body, and classify each occurrence as - * read / write / declaration — i.e. stop being a regex census. That was out - * of scope for objectui#8703. ⛔ Until it exists this file stays OUT of CI: - * a matcher-dependent instrument must not become a gate. - * - * objectui#8690's nine sites are inside the UNION of the two modes, minus - * `packages/permissions` (which objectui#8688 / PR #8689 already hold). - * - * Usage: node scripts/census-recorder-wait-shape.mjs [--recorder-match=ident|path] + * preceding `.`. Four consequences, each FORCED on a fixture — the fixtures are + * committed next to this file's pin test and every one of them was observed + * producing the wrong answer on the pre-objectui#8704 script: + * + * M1 The two modes are INCOMPARABLE BY CONSTRUCTION. `path` alone sees a + * recorder pushed and read as the same member path (fixture f1); `ident` + * alone sees one pushed as a member and read under a bare alias (f2). + * M2 A recorder pushed bare and read through a host that holds the SAME + * array is missed by BOTH modes (f3) — a shared blind spot, not a `path` + * one. ⛔ An earlier header called this a `path`-only miss. It is not: + * the lookbehind forbids a dotted read in BOTH modes, so NO mode of the + * regex instrument can see that shape. + * D1 The forward window ends at the next textual `await` IN THE FILE, not at + * the end of the enclosing test. A wait that is the last `await` of its + * test gets a window running on into the NEXT test (f4, cases a/b and + * d/e), and the same truncation LOSES a genuine hazard sitting one + * ordinary `await` further on (f5). + * D2 ANY textual occurrence counts as a "read"; only `X.push(` is excluded. + * A declaration, a destructuring, a reset (`gridSchemas.length = 0`), + * even a parameter named `log`, all register as reads (f4, case c). + * + * Of the 18 strict flags the `path` mode reported at da5e4f69e, SEVEN point at + * something that is not a read at all — objectui#8703 read all seven and + * repaired none. + * + * ⚠️ M1 is a claim about SHAPES, and it is FALSE as a measurement of this tree. + * Run `--matcher=regex` in both modes and diff the site lists: the strict + * buckets are NESTED, not disjoint. + * + * at da5e4f69e ident 15 ⊂ path 18 ident-only EMPTY, 3 path-only + * with main @ a9bc02996 ident 12 ⊂ path 15 ident-only EMPTY, the SAME 3 + * + * and in both readings the three path-only sites are the `server.saved` / + * `server.savedOpts` reads in `PermissionMatrixEditor.{scope,packageDoorFacets} + * .test.tsx`. The union of the two modes is just `path`'s bucket. ⛔ Never cite + * M1 as a reason the two modes must be run and unioned — on this tree that buys + * nothing. + * + * ⭐ Which is the short way to see that the mode choice was never the largest + * source of movement. At da5e4f69e the mode choice separates THREE sites; D1 + * and D2 together separate SIXTEEN. `path` 18 → AST 20 is not +2: it is −7 (the + * seven non-reads above) and +9 (seven recorders no name matcher could follow + * through a host, a factory or a destructuring, plus two reads D1 had truncated + * away). The mode was the visible knob and the smallest one. + * + * --------------------------------------------------------------------------- + * WHAT THE AST MATCHER DOES INSTEAD (objectui#8704) + * --------------------------------------------------------------------------- + * + * 1. IDENTITY, not spelling. Every identifier is resolved to its binding over + * a scope chain, and six forms are unioned onto one key, so the same array + * object is one recorder however it is written: + * `const a = b` · `const { p } = obj` + * `const obj = { p }` · `const obj = { p: expr }` + * `f(arr)` reaching `arr` through f's PARAMETER (one hop) + * `const h = f()` / `const { p } = f()` through f's (one hop) + * single returned object literal + * Canonicalisation is PREFIX-AWARE: an alias declared on `server` has to + * reach `server.savedOpts` below it, and comparing whole keys does not do + * that. + * ⇒ the `--recorder-match` mode choice DISAPPEARS. It was an artefact of + * matching names. + * ⚠️ The two hops are why this matcher agrees with all 16 hand-verified + * labels. Without the parameter hop the three `PermissionMatrixEditor` + * reads go dark; without the return hop `ObjectChart.optionColors` and + * `DatasetWidget.relabel` do. All five are hand-verified GENUINE reads. + * 2. TEST-SCOPED WINDOWS, in statements. The window is the statements that + * follow the wait inside its ENCLOSING FUNCTION BODY (a test callback, a + * helper) and stops at the function's end — it can never reach the next + * `it`. Within that, it stops at the next awaited SETTLING ANCHOR + * (`waitFor`, `waitForElementToBeRemoved`, `findBy*`/`findAllBy*`), + * because that is what re-anchors the reads after it. ⚠️ A plain `await` + * does NOT close the window: `await Promise.resolve()` settles nothing, + * and treating it as an anchor is what made the regex matcher blind on f5. + * `await act(...)` is likewise not an anchor — it drains effects, it does + * not wait for a recorder to fill. + * ⚠️ This repairs only ONE HALF of D1 — the runaway half. R2' below is + * the half that survives, pointed the other way. + * 3. READ / WRITE / DECLARATION. An occurrence is a DECLARATION when the node + * is a binding name (variable, binding element, parameter, function, + * class, import) or an object-literal key; a WRITE when it is the target + * of an assignment (`x = …`, `x.length = 0`, `x[0] = …`, `x++`, `delete`) + * or the receiver of `push` / `unshift`; a READ otherwise. Only reads + * flag. ⚠️ Deliberately conservative: `pop`, `shift` and `splice` observe + * contents, so they count as READS. Over-flagging is recoverable by + * reading the site; going blind is not. + * + * ⇒ Fixtures f1, f2, f3, f5 and f6 flag; f4 does not. Every one of those six + * answers was observed WRONG on the pre-repair script first + * (`scripts/__tests__/census-recorder-wait-shape.test.ts` holds the matrix). + * + * --------------------------------------------------------------------------- + * ⛔ THE CAVEAT STAYS. A COUNT PRINTED HERE IS STILL NOT A CORPUS FACT. + * --------------------------------------------------------------------------- + * + * Two of the three error sources objectui#8703 named are gone outright, and + * HALF of the third (R2' below is the surviving half); the six fixtures prove + * that much. Measured over the same tree the earlier numbers were read on + * (da5e4f69e, 2776 tracked test files): + * + * regex ident 159 flags, 15 strict, 10 files (as objectui#8703 published) + * regex path 167 flags, 18 strict, 12 files (as objectui#8703 published) + * AST 138 flags, 20 strict, 12 files + * + * and the strict delta decomposes EXACTLY: −7, which are precisely the seven + * objectui#8703 read and found were not reads at all, and +9 — seven recorders + * the name matcher could not see through a host, a factory or a destructuring, + * and two reads it had truncated away behind an ordinary `await`. Two of the + * nine are hand-verified genuine reads the old matcher had LOST. + * + * And a count moves with the TREE alone, which this branch's own merge with + * main measured on a byte-identical matcher: merging a9bc02996 — where #8707, + * #8711 and #8713 had each anchored a wait — moves the population 2776 → 2786 + * and every strict bucket down, AST 20 → 18, `path` 18 → 15, `ident` 15 → 12. + * Nothing about the instrument changed. The tree did. + * + * That makes the number better, and STILL NOT QUOTABLE. What is left, measured + * rather than supposed: + * + * R1 No type checker. Identity is resolved syntactically, ONE FILE at a + * time, and the two interprocedural rules above are ONE HOP each. An + * array reaching a test from another MODULE, through two helpers, or out + * of a factory with more than one `return`, is still a different key from + * the array the helper pushes into. There is no measurement of how many + * exist — by construction, the instrument cannot count what it cannot see. + * R1' The parameter hop OVER-MERGES: a helper called with different arrays + * unions them onto one key. That can only ever LOSE a flag — the wait set + * then already contains the read's key — never invent one, and the + * test-scoped window keeps it from reaching across tests. Unmeasured, for + * the same reason as R1. + * R2 The window rule is a JUDGEMENT about which awaits settle a recorder, + * not a fact about the code. `await act(…)` is deliberately NOT an anchor; + * moving it into the set removes at least one flag measured here + * (`rowRecordCrudVerdict.test.tsx`), and neither choice is provably right. + * R2' The window is scoped to the ENCLOSING FUNCTION BODY, and that repairs + * only ONE half of D1. The runaway half is gone: a window can never + * reach the next `it`. The truncation half SURVIVES, pointed the other + * way — when the wait is hosted in a helper the test awaits, or in any + * inner callback, the window is that inner body and the CALLER's + * statements sit outside it. Forced on a probe: `await settleSaves(); + * expect(deletes).toEqual([])` draws ZERO, while the identical read with + * the wait inlined into the test draws a flag. Not a corner — measured + * on this tree, 503 of the 3945 `await waitFor(...)` sites have their + * window owned by an inner function, 464 of them a named or arrow + * helper. The regex matcher was blind here too, differently (its textual + * window ran past the helper's closing brace rather than into the + * caller), so this is a SURVIVING blind spot and not a regression — with + * one measured exception: for a wait inside an `await act(...)` callback + * the regex window did reach the read and this one does not (forced on a + * constructed probe; zero such sites in this tree). + * R3 A flag is still not a defect, and this is the residual that matters + * most. objectui#8690 read its nine and found ONE worth repairing; + * objectui#8703 read seven more and repaired none. All 16 hand-verified + * labels agree with this matcher — and that agreement says the list is + * now a good list of PLACES TO READ, not that anything on it is wrong. + * 8 of the 9 genuine reads were, and remain, SOUND BY CONSTRUCTION, which + * no matcher can see. + * + * ⇒ Quote a number from here as "sites this instrument points at", never as + * "sites of this shape", and never as "defects". ⛔ This file stays OUT of + * CI: R2 and R2' alone make it matcher-dependent, and a gate on this list would + * institutionalise the batch repair objectui#8690 exists to prevent. Its pin + * test runs the matcher over the six committed FIXTURES only — never over the + * corpus — so nothing in CI depends on what the corpus reads. + * + * Usage: + * node scripts/census-recorder-wait-shape.mjs + * node scripts/census-recorder-wait-shape.mjs --matcher=regex --recorder-match=ident + * node scripts/census-recorder-wait-shape.mjs --files a.test.ts b.test.ts */ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +import ts from 'typescript'; + +import { isEntrypoint } from './invoked-as.mjs'; -const mode = (process.argv.find((a) => a.startsWith('--recorder-match=')) ?? '').split('=')[1] || 'path'; -if (!['ident', 'path'].includes(mode)) { - console.error(`unknown --recorder-match=${mode} (expected ident|path)`); - process.exit(2); +// --------------------------------------------------------------------------- +// The regex matcher — objectui#8690's census, unchanged. Kept reproducible. +// --------------------------------------------------------------------------- + +/** + * @param {string[]} files + * @param {'ident'|'path'} mode + */ +export function analyzeRegex(files, mode) { + const PUSH = mode === 'ident' + ? /([A-Za-z_$][\w$]*)\s*\.push\s*\(/g + : /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\.push\s*\(/g; + const nameRe = (n) => new RegExp(`(? src.slice(0, index).split('\n').length; + + const flags = []; + for (const file of files) { + const src = readFileSync(file, 'utf8'); + const recorders = new Set(); + for (const m of src.matchAll(PUSH)) recorders.add(m[1]); + if (recorders.size === 0) continue; + + for (const w of src.matchAll(/await\s+waitFor\s*\(/g)) { + const open = w.index + w[0].length - 1; + let depth = 0; + let end = -1; + for (let j = open; j < src.length; j++) { + if (src[j] === '(') depth++; + else if (src[j] === ')' && --depth === 0) { end = j; break; } + } + if (end < 0) continue; + const waitBody = src.slice(open, end + 1); + const waitSet = new Set([...recorders].filter((r) => nameRe(r).test(waitBody))); + + const rest = src.slice(end + 1); + const nextAwait = rest.search(/\bawait\b/); + const window = nextAwait === -1 ? rest : rest.slice(0, nextAwait); + + for (const r of recorders) { + if (waitSet.has(r)) continue; + for (const hit of window.matchAll(nameRe(r))) { + if (/^\s*\.push\s*\(/.test(window.slice(hit.index + r.length))) continue; + flags.push({ + file, + line: lineOf(src, end + 1 + hit.index), + waitLine: lineOf(src, w.index), + recorder: r, + waitSet: [...waitSet], + }); + break; + } + } + } + } + return flags; +} + +// --------------------------------------------------------------------------- +// The AST matcher — objectui#8704. +// --------------------------------------------------------------------------- + +/** Awaited calls that settle a condition, and so re-anchor the reads after them. */ +const SETTLING_ANCHORS = new Set(['waitFor', 'waitForElementToBeRemoved']); +const isFindByQuery = (name) => /^find(?:All)?By[A-Z]/.test(name); + +/** Array methods that only WRITE. `pop`/`shift`/`splice` observe contents — reads. */ +const PURE_MUTATORS = new Set(['push', 'unshift']); + +const isFunctionLike = (n) => + ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) + || ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n) + || ts.isGetAccessor(n) || ts.isSetAccessor(n); + +/** A union-find over identity keys, so four alias forms collapse to one recorder. */ +function makeAliases() { + const parent = new Map(); + const find = (k) => { + let root = k; + while (parent.has(root) && parent.get(root) !== root) root = parent.get(root); + let cur = k; + while (parent.has(cur) && parent.get(cur) !== cur) { + const next = parent.get(cur); + parent.set(cur, root); + cur = next; + } + return root; + }; + return { + find, + union(a, b) { + if (!a || !b) return; + const ra = find(a); + const rb = find(b); + if (ra === rb) return; + parent.set(ra, rb); + parent.set(rb, rb); + }, + }; } -const files = execSync("git ls-files '*.test.ts' '*.test.tsx'", { - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, -}).split('\n').filter(Boolean); - -const PUSH = mode === 'ident' - ? /([A-Za-z_$][\w$]*)\s*\.push\s*\(/g - : /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\.push\s*\(/g; -const nameRe = (n) => new RegExp(`(? src.slice(0, index).split('\n').length; - -const flags = []; -for (const file of files) { - const src = readFileSync(file, 'utf8'); - const recorders = new Set(); - for (const m of src.matchAll(PUSH)) recorders.add(m[1]); - if (recorders.size === 0) continue; - - for (const w of src.matchAll(/await\s+waitFor\s*\(/g)) { - // 2. balance parens to the end of the wait - const open = w.index + w[0].length - 1; - let depth = 0; - let end = -1; - for (let j = open; j < src.length; j++) { - if (src[j] === '(') depth++; - else if (src[j] === ')' && --depth === 0) { end = j; break; } - } - if (end < 0) continue; - const waitBody = src.slice(open, end + 1); - const waitSet = new Set([...recorders].filter((r) => nameRe(r).test(waitBody))); - - // 3. forward window: end of the wait -> the next `await` (or EOF) - const rest = src.slice(end + 1); - const nextAwait = rest.search(/\bawait\b/); - const window = nextAwait === -1 ? rest : rest.slice(0, nextAwait); - - for (const r of recorders) { - if (waitSet.has(r)) continue; - for (const hit of window.matchAll(nameRe(r))) { - if (/^\s*\.push\s*\(/.test(window.slice(hit.index + r.length))) continue; - flags.push({ - file, - line: lineOf(src, end + 1 + hit.index), - waitLine: lineOf(src, w.index), - recorder: r, - waitSet: [...waitSet], +/** + * Resolve every identifier REFERENCE in a source file to the binding it names, + * and collect the alias unions. Returns the pieces the passes below need. + * + * Scoping is approximate on purpose — no type checker, one file at a time (R1 + * in the header). Declarations are hoisted into their scope before the scope's + * children are walked, so use-before-declare resolves the same as after. + * + * @param {ts.SourceFile} sf + */ +function resolveBindings(sf) { + const aliases = makeAliases(); + /** @type {Map} reference identifier -> binding key */ + const resolved = new Map(); + /** @type {Set} identifier nodes that are DECLARATION names */ + const declNames = new Set(); + + const keyOfBinding = (nameNode) => `d${nameNode.pos}:${nameNode.getText(sf)}`; + + const scopeOf = (parentScope) => ({ parent: parentScope, names: new Map() }); + const lookup = (scope, name) => { + for (let s = scope; s; s = s.parent) if (s.names.has(name)) return s.names.get(name); + return null; + }; + + /** Every identifier a binding name introduces, including destructuring. */ + const bindingIdentifiers = (nameNode, out) => { + if (ts.isIdentifier(nameNode)) { out.push(nameNode); return; } + if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) { + for (const el of nameNode.elements) { + if (ts.isBindingElement(el)) bindingIdentifiers(el.name, out); + } + } + }; + + const declare = (scope, nameNode) => { + const ids = []; + bindingIdentifiers(nameNode, ids); + for (const id of ids) { + declNames.add(id); + scope.names.set(id.text, keyOfBinding(id)); + } + }; + + // Pass A: build the scope tree and the name tables, hoisting per scope. + const scopeFor = new Map(); + const buildScopes = (node, scope) => { + let inner = scope; + if (node === sf || ts.isBlock(node) || ts.isModuleBlock(node) || isFunctionLike(node) + || ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) + || ts.isCatchClause(node) || ts.isCaseBlock(node) || ts.isClassDeclaration(node)) { + inner = scopeOf(scope); + } + scopeFor.set(node, inner); + + if (isFunctionLike(node)) for (const p of node.parameters) declare(inner, p.name); + if (ts.isCatchClause(node) && node.variableDeclaration) declare(inner, node.variableDeclaration.name); + + // Hoist the declarations this scope owns before descending. + const hoist = (n) => { + if (n !== node && (ts.isBlock(n) || isFunctionLike(n) || ts.isModuleBlock(n))) { + // A nested block owns its own `const`/`let`; only `var` and function + // declarations would climb out, and test files do not rely on that. + if (ts.isFunctionDeclaration(n) && n.name) declare(inner, n.name); + return; + } + if (ts.isVariableDeclaration(n)) declare(inner, n.name); + else if (ts.isFunctionDeclaration(n) && n.name) declare(inner, n.name); + else if (ts.isClassDeclaration(n) && n.name) declare(inner, n.name); + else if (ts.isImportSpecifier(n) || ts.isImportClause(n) || ts.isNamespaceImport(n)) { + if (n.name) declare(inner, n.name); + } + ts.forEachChild(n, hoist); + }; + ts.forEachChild(node, hoist); + + ts.forEachChild(node, (child) => buildScopes(child, inner)); + }; + buildScopes(sf, null); + + // Pass B: resolve references, and record the alias unions. + const pathOf = (node) => { + const parts = []; + let cur = node; + while (ts.isPropertyAccessExpression(cur)) { + parts.unshift(cur.name.text); + cur = cur.expression; + while (ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) + || ts.isNonNullExpression(cur) || ts.isSatisfiesExpression(cur)) cur = cur.expression; + } + if (!ts.isIdentifier(cur)) return null; + return { root: cur, parts }; + }; + + /** `(x)`, `x as T`, `x!` and `x satisfies T` all still name `x`. */ + const unwrap = (node) => { + let cur = node; + for (;;) { + if (ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) + || ts.isNonNullExpression(cur) || ts.isSatisfiesExpression(cur) + || ts.isTypeAssertionExpression(cur)) { + cur = cur.expression; + } else return cur; + } + }; + + /** Identity key of an identifier / dotted path expression, or null. */ + const identityOf = (raw) => { + const node = unwrap(raw); + const p = pathOf(node); + if (!p) return null; + const rootKey = resolved.get(p.root) ?? (declNames.has(p.root) ? null : `free:${p.root.text}`); + if (!rootKey) return null; + return [rootKey, ...p.parts].join('.'); + }; + + // Resolve references against the scope map built above. + const resolveWithScope = (node, scope) => { + const here = scopeFor.get(node) ?? scope; + if (ts.isIdentifier(node) && !declNames.has(node)) { + const parent = node.parent; + const isPropertyName = parent + && ((ts.isPropertyAccessExpression(parent) && parent.name === node) + || (ts.isPropertyAssignment(parent) && parent.name === node) + || (ts.isBindingElement(parent) && parent.propertyName === node) + || (ts.isMethodDeclaration(parent) && parent.name === node) + || (ts.isPropertyDeclaration(parent) && parent.name === node) + || (ts.isPropertySignature(parent) && parent.name === node)); + if (!isPropertyName) { + const key = lookup(here, node.text); + if (key) resolved.set(node, key); + } + } + ts.forEachChild(node, (child) => resolveWithScope(child, here)); + }; + resolveWithScope(sf, scopeFor.get(sf)); + + // Pass C: alias unions. Every form here means "the same array object". + const objectLiteralAliases = (ownerKey, obj) => { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const rhs = identityOf(prop.initializer); + if (rhs) aliases.union(`${ownerKey}.${prop.name.text}`, rhs); + } else if (ts.isShorthandPropertyAssignment(prop)) { + const rhs = resolved.get(prop.name) ?? null; + if (rhs) aliases.union(`${ownerKey}.${prop.name.text}`, rhs); + } + } + }; + + // Which binding key names which function, so a call can be matched to its + // parameter list one hop deep (see `collectAliases` below). + /** @type {Map} */ + const functionOf = new Map(); + const collectFunctions = (node) => { + if (ts.isFunctionDeclaration(node) && node.name) functionOf.set(keyOfBinding(node.name), node); + else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer + && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) { + functionOf.set(keyOfBinding(node.name), node.initializer); + } + ts.forEachChild(node, collectFunctions); + }; + collectFunctions(sf); + + /** `{ a }` binds `a` from property `a`; `{ a: b }` binds `b` from `a`. */ + const propertyNameOf = (el) => (el.propertyName && ts.isIdentifier(el.propertyName) + ? el.propertyName.text + : el.name.text); + + /** The single expression a same-file helper returns, or null. */ + const singleReturnExpression = (fn) => { + if (!fn.body) return null; + if (!ts.isBlock(fn.body)) return fn.body; // concise arrow body + const returns = []; + const walk = (n) => { + if (n !== fn.body && isFunctionLike(n)) return; + if (ts.isReturnStatement(n) && n.expression) returns.push(n.expression); + ts.forEachChild(n, walk); + }; + walk(fn.body); + return returns.length === 1 ? returns[0] : null; + }; + + const collectAliases = (node) => { + // ONE HOP INTERPROCEDURAL. A recorder pushed inside a same-file helper is + // reached there through the helper's PARAMETER, which is a different + // binding from the array the test holds — measured on + // `PermissionMatrixEditor.{scope,packageDoorFacets}.test.tsx`, where it + // cost three hand-verified genuine reads. At a direct call the parameter + // and the argument ARE the same array object, so they are unioned. + // ⚠️ A helper called with DIFFERENT arrays merges them onto one key. That + // over-merge can only ever LOSE a flag (the wait set then already contains + // the read's key), never invent one — and the test-scoped window keeps it + // from reaching across tests. It is R1' in this file's header. + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const fn = functionOf.get(resolved.get(node.expression) ?? ''); + if (fn) { + for (let i = 0; i < node.arguments.length && i < fn.parameters.length; i++) { + const param = fn.parameters[i]; + if (!ts.isIdentifier(param.name)) continue; + const arg = identityOf(node.arguments[i]); + if (arg) aliases.union(keyOfBinding(param.name), arg); + } + } + } + if (ts.isVariableDeclaration(node) && node.initializer) { + if (ts.isIdentifier(node.name)) { + const lhs = keyOfBinding(node.name); + const init = unwrap(node.initializer); + const rhs = identityOf(init); + if (rhs) aliases.union(lhs, rhs); + else if (ts.isObjectLiteralExpression(init)) { + objectLiteralAliases(lhs, init); + } else if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) { + // `const host = makeHost()` — the factory's returned object literal + // holds the very arrays its body pushes into. Same file, one hop. + // Measured: without it, `ObjectChart.optionColors` and + // `DatasetWidget.relabel` — both hand-verified GENUINE reads by + // objectui#8690 — are invisible. + const fn = functionOf.get(resolved.get(init.expression) ?? ''); + const ret = fn ? singleReturnExpression(fn) : null; + if (ret) { + const retExpr = unwrap(ret); + if (ts.isObjectLiteralExpression(retExpr)) objectLiteralAliases(lhs, retExpr); + else { + const r = identityOf(retExpr); + if (r) aliases.union(lhs, r); + } + } + } + } else if (ts.isObjectBindingPattern(node.name)) { + const init = unwrap(node.initializer); + const src = identityOf(init); + // `const { p } = obj` — p IS obj.p. + if (src) { + for (const el of node.name.elements) { + if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; + aliases.union(keyOfBinding(el.name), `${src}.${propertyNameOf(el)}`); + } + } else if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) { + // `const { ctxSeen } = makeProbe()` — the SAME hop as `const host = + // makeHost()`, one destructuring further. Without it the test's + // `ctxSeen` is a fresh binding with no link to the array the factory + // pushes into, and the site goes dark. + const fn = functionOf.get(resolved.get(init.expression) ?? ''); + const ret = fn ? singleReturnExpression(fn) : null; + const retExpr = ret ? unwrap(ret) : null; + if (retExpr && ts.isObjectLiteralExpression(retExpr)) { + const byName = new Map(); + for (const prop of retExpr.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + byName.set(prop.name.text, identityOf(prop.initializer)); + } else if (ts.isShorthandPropertyAssignment(prop)) { + byName.set(prop.name.text, resolved.get(prop.name) ?? null); + } + } + for (const el of node.name.elements) { + if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; + const rhs = byName.get(propertyNameOf(el)); + if (rhs) aliases.union(keyOfBinding(el.name), rhs); + } + } + } + } + } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const lhs = identityOf(node.left); + const rhs = identityOf(node.right); + if (lhs && rhs) aliases.union(lhs, rhs); + else if (lhs && ts.isObjectLiteralExpression(node.right)) objectLiteralAliases(lhs, node.right); + } + ts.forEachChild(node, collectAliases); + }; + collectAliases(sf); + + // Canonicalisation is PREFIX-AWARE. `union` relates whole keys, but an alias + // is usually declared on a prefix (`makeClient(server)` relates the two + // `server` bindings) while the recorder is a path below it + // (`server.savedOpts`). Rewriting the longest aliased prefix and re-resolving + // is what makes the two meet; comparing whole strings does not, and that is + // what lost the three `PermissionMatrixEditor` reads on the first draft. + const memo = new Map(); + const canonical = (key) => { + if (!key) return null; + if (memo.has(key)) return memo.get(key); + memo.set(key, key); // cycle guard: an unresolved key answers itself + let out = key; + const direct = aliases.find(key); + if (direct !== key) { + out = canonical(direct); + } else { + const cut = key.lastIndexOf('.'); + if (cut !== -1) { + const head = key.slice(0, cut); + const headCanon = canonical(head); + if (headCanon !== head) out = canonical(headCanon + key.slice(cut)); + } + } + memo.set(key, out); + return out; + }; + + return { aliases, resolved, declNames, identityOf, canonical }; +} + +/** + * Classify what an occurrence of `node` (the expression naming the recorder) + * does to it: 'declare' | 'write' | 'read'. + */ +function classifyOccurrence(node, declNames) { + if (ts.isIdentifier(node) && declNames.has(node)) return 'declare'; + + const parent = node.parent; + if (parent) { + // An object-literal key is a name, not a reference. A SHORTHAND property + // (`{ calls }`) is a genuine read of `calls`, and is deliberately not here. + if (ts.isPropertyAssignment(parent) && parent.name === node) return 'declare'; + // `x.push(...)` / `x.unshift(...)` — the recorder is the receiver. + if (ts.isPropertyAccessExpression(parent) && parent.expression === node + && PURE_MUTATORS.has(parent.name.text) + && ts.isCallExpression(parent.parent) && parent.parent.expression === parent) { + return 'write'; + } + } + + // Climb the whole access chain rooted at the recorder: `x`, `x.length`, + // `x[0].name`. Whatever the chain ends in decides read vs write. + let top = node; + while (top.parent + && ((ts.isPropertyAccessExpression(top.parent) && top.parent.expression === top) + || (ts.isElementAccessExpression(top.parent) && top.parent.expression === top))) { + top = top.parent; + } + const p = top.parent; + if (!p) return 'read'; + if (ts.isBinaryExpression(p) && p.left === top + && p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment + && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment) { + return 'write'; + } + if ((ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) + && p.operand === top + && (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken)) { + return 'write'; + } + if (ts.isDeleteExpression(p)) return 'write'; + return 'read'; +} + +/** Does this statement contain an awaited settling anchor, outside nested functions? */ +function containsSettlingAwait(stmt) { + let found = false; + const walk = (n) => { + if (found) return; + if (n !== stmt && isFunctionLike(n)) return; + if (ts.isAwaitExpression(n) && ts.isCallExpression(n.expression)) { + const callee = n.expression.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : (ts.isPropertyAccessExpression(callee) ? callee.name.text : null); + if (name && (SETTLING_ANCHORS.has(name) || isFindByQuery(name))) { found = true; return; } + } + ts.forEachChild(n, walk); + }; + walk(stmt); + return found; +} + +/** + * The statements a wait's window covers: everything after the wait's own + * statement, inside the ENCLOSING FUNCTION BODY, stopping at the next settling + * anchor. It can never cross into the next `it`. + */ +function windowStatements(awaitNode) { + // The statement the wait sits in, and the function body that owns it. + let stmt = awaitNode; + while (stmt.parent && !(ts.isBlock(stmt.parent) || ts.isSourceFile(stmt.parent) || ts.isCaseClause(stmt.parent) || ts.isDefaultClause(stmt.parent))) { + stmt = stmt.parent; + } + if (!stmt.parent) return []; + + let fnBody = null; + for (let n = awaitNode.parent; n; n = n.parent) { + if (isFunctionLike(n)) { fnBody = n.body ?? null; break; } + if (ts.isSourceFile(n)) { fnBody = n; break; } + } + + const out = []; + let cursor = stmt; + for (;;) { + const container = cursor.parent; + const list = container.statements; + if (!list) break; + const idx = list.indexOf(cursor); + if (idx < 0) break; + for (let i = idx + 1; i < list.length; i++) out.push(list[i]); + if (container === fnBody || ts.isSourceFile(container)) break; + // Climb out of a nested block (an `if`, a `try`) but never out of the test. + let next = container; + while (next.parent && !(ts.isBlock(next.parent) || ts.isSourceFile(next.parent))) next = next.parent; + if (!next.parent || isFunctionLike(container.parent)) break; + cursor = next; + } + + const stop = out.findIndex(containsSettlingAwait); + return stop === -1 ? out : out.slice(0, stop); +} + +/** + * @param {string[]} files + * @returns {{file:string,line:number,waitLine:number,recorder:string,waitSet:string[]}[]} + */ +export function analyzeAst(files) { + const flags = []; + for (const file of files) { + const src = readFileSync(file, 'utf8'); + if (!src.includes('waitFor')) continue; + const sf = ts.createSourceFile( + file, + src, + { languageVersion: ts.ScriptTarget.Latest, jsDocParsingMode: ts.JSDocParsingMode.ParseNone }, + /* setParentNodes */ true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const { declNames, identityOf, canonical } = resolveBindings(sf); + const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1; + + // 1. Recorders: the receiver of every `X.push(...)`, by binding identity. + /** @type {Map} canonical key -> a readable spelling */ + const recorders = new Map(); + const findRecorders = (n) => { + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) + && n.expression.name.text === 'push') { + const key = canonical(identityOf(n.expression.expression)); + if (key) recorders.set(key, n.expression.expression.getText(sf)); + } + ts.forEachChild(n, findRecorders); + }; + findRecorders(sf); + if (recorders.size === 0) continue; + + /** The recorder an identifier occurrence names, longest path prefix first. */ + const recorderAt = (id) => { + const chain = [id]; + let node = id; + while (node.parent && ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node) { + node = node.parent; + chain.push(node); + } + for (let i = chain.length - 1; i >= 0; i--) { + const key = canonical(identityOf(chain[i])); + if (key && recorders.has(key)) return { key, node: chain[i] }; + } + return null; + }; + + // A dotted path is entered at its ROOT identifier exactly once; the + // identifiers in property-name position are names, not references, so + // skipping them is what keeps `x.foo` from colliding with a free `foo`. + const isPropertyNamePosition = (n) => { + const p = n.parent; + return !!p && ((ts.isPropertyAccessExpression(p) && p.name === n) + || (ts.isPropertyAssignment(p) && p.name === n) + || (ts.isBindingElement(p) && p.propertyName === n) + || (ts.isMethodDeclaration(p) && p.name === n) + || (ts.isPropertyDeclaration(p) && p.name === n) + || (ts.isPropertySignature(p) && p.name === n)); + }; + + const eachRecorderOccurrence = (root, cb) => { + const walk = (n) => { + if (ts.isIdentifier(n) && !isPropertyNamePosition(n)) { + const hit = recorderAt(n); + if (hit) cb(hit, n); + } + ts.forEachChild(n, walk); + }; + walk(root); + }; + + // 2. Every `await waitFor(...)`. + const waits = []; + const findWaits = (n) => { + if (ts.isAwaitExpression(n) && ts.isCallExpression(n.expression)) { + const callee = n.expression.expression; + if (ts.isIdentifier(callee) && callee.text === 'waitFor') waits.push(n); + } + ts.forEachChild(n, findWaits); + }; + findWaits(sf); + + for (const wait of waits) { + const waitSet = new Set(); + const waitSpelling = new Map(); + for (const arg of wait.expression.arguments) { + eachRecorderOccurrence(arg, (hit) => { + waitSet.add(hit.key); + // Spell a recorder as the WAIT writes it, not as its push site does: + // two distinct arrays can share a push-site spelling, and printing + // that makes a correct flag read as "waits [calls] reads calls". + waitSpelling.set(hit.key, hit.node.getText(sf)); + }); + } + + // 3. The forward window, scoped to the enclosing test body. + const seen = new Set(); + for (const stmt of windowStatements(wait)) { + eachRecorderOccurrence(stmt, (hit, id) => { + if (waitSet.has(hit.key) || seen.has(hit.key)) return; + if (classifyOccurrence(hit.node, declNames) !== 'read') return; + seen.add(hit.key); + flags.push({ + file, + line: lineOf(id.getStart(sf)), + waitLine: lineOf(wait.getStart(sf)), + recorder: hit.node.getText(sf), + waitSet: [...waitSet].map((k) => waitSpelling.get(k) ?? recorders.get(k) ?? k), + }); }); - break; // one flag per (wait, recorder) } } } + return flags; } -const strict = flags.filter((f) => f.waitSet.length > 0); -console.log(`recorder-match: ${mode}`); -console.log(`population: ${files.length} test files`); -console.log(`total flags: ${flags.length}`); -console.log(` wait named a recorder (the strict shape): ${strict.length} in ${new Set(strict.map((f) => f.file)).size} files`); -console.log(` wait named no recorder (a DOM node, a hook result, a test id): ${flags.length - strict.length}`); -console.log( - '⚠️ these counts are INSTRUMENT READINGS, not corpus facts — about a third\n' + - ' of the strict bucket is not a read at all. Read this file\'s header\n' + - ' (objectui#8703) before quoting any number below.', -); -console.log('--- strict-shape sites (READ each one; this list is not a defect list) ---'); -for (const f of strict.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) { - console.log(`${f.file}:${f.line} wait@${f.waitLine} waits [${f.waitSet.join(', ')}] reads ${f.recorder}`); +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +export function trackedTestFiles() { + return execSync("git ls-files '*.test.ts' '*.test.tsx'", { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }).split('\n').filter(Boolean); } + +function main() { + const argOf = (flag) => { + const hit = process.argv.find((a) => a.startsWith(`${flag}=`)); + return hit ? hit.slice(flag.length + 1) : null; + }; + const matcher = argOf('--matcher') ?? 'ast'; + const mode = argOf('--recorder-match') ?? 'path'; + if (!['ast', 'regex'].includes(matcher)) { + console.error(`unknown --matcher=${matcher} (expected ast|regex)`); + process.exit(2); + } + if (!['ident', 'path'].includes(mode)) { + console.error(`unknown --recorder-match=${mode} (expected ident|path)`); + process.exit(2); + } + + const filesIdx = process.argv.indexOf('--files'); + const files = filesIdx === -1 + ? trackedTestFiles() + : process.argv.slice(filesIdx + 1).filter((a) => !a.startsWith('--')); + + const flags = matcher === 'ast' ? analyzeAst(files) : analyzeRegex(files, mode); + const strict = flags.filter((f) => f.waitSet.length > 0); + + console.log(`matcher: ${matcher}${matcher === 'regex' ? ` (--recorder-match=${mode})` : ''}`); + console.log(`population: ${files.length} test files`); + console.log(`total flags: ${flags.length}`); + console.log(` wait named a recorder (the strict shape): ${strict.length} in ${new Set(strict.map((f) => f.file)).size} files`); + console.log(` wait named no recorder (a DOM node, a hook result, a test id): ${flags.length - strict.length}`); + console.log( + matcher === 'ast' + ? '⚠️ READINGS, not corpus facts. objectui#8704 removed name-matching and\n' + + ' declarations-read-as-reads outright, and only the RUNAWAY half of the\n' + + ' window rule: a wait hosted in a helper still cannot see its caller.\n' + + ' R1-R3 in this file\'s header are what is left, and a flag is still a\n' + + ' site to READ, never a defect. ⛔ Not a gate.' + : '⚠️ the ORIGINAL objectui#8690 matcher, kept only so its published numbers\n' + + ' stay reproducible. About a third of its strict bucket is not a read at\n' + + ' all (objectui#8703). ⛔ Never quote it — run the default AST matcher.', + ); + console.log('--- strict-shape sites (READ each one; this list is not a defect list) ---'); + for (const f of strict.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) { + console.log(`${f.file}:${f.line} wait@${f.waitLine} waits [${f.waitSet.join(', ')}] reads ${f.recorder}`); + } +} + +if (isEntrypoint(import.meta.url)) main();