From bf252aae0f0ebbadf8481c9a6526b0399a195ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:14:51 +0000 Subject: [PATCH 1/5] test(plugin-designer): anchor the lookupKeying absence pin on the handler's reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expect(deletes).toEqual([])` was dated to the first PUT by `waitFor(puts)`. That covers the delete scan only because the scan happens to run before the save loop in `handleObjectsChange` — a property of the page, not one the file asserts. Anchor it on `reload()`, the handler's last statement, so the emptiness is read after every write the handler makes. Measured: with a stray `reset` issued after the saves, the `puts` wait leaves this assertion green while C0/H1/H2 in the same file — which already anchor on the reload — go red. objectui#8690 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../src/MetadataObjectsPage.lookupKeying.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/plugin-designer/src/MetadataObjectsPage.lookupKeying.test.tsx b/packages/plugin-designer/src/MetadataObjectsPage.lookupKeying.test.tsx index 76b074a118..53ea1edd39 100644 --- a/packages/plugin-designer/src/MetadataObjectsPage.lookupKeying.test.tsx +++ b/packages/plugin-designer/src/MetadataObjectsPage.lookupKeying.test.tsx @@ -377,6 +377,18 @@ describe('objectui#6522 · SITE B — the raw-payload lookup keys every server o // the real payload rather than something off the prototype chain. expect(puts[0].body.pluralLabel).toBe('Constructors'); expect(puts[0].body.fields).toBeDefined(); + // COMPLETION ANCHOR (objectui#8690). The emptiness below is only worth + // reading once `handleObjectsChange` has run to the END: `reload()` is its + // last statement, so the manager receiving the re-read list is the signal + // that every write the handler was going to make has been made. Waiting on + // `puts` instead dates the absence to the FIRST write, which covers the + // delete scan only because that scan happens to run before the save loop — + // a property of the page, not one this file asserts. Measured: with a stray + // `reset` issued after the saves, the `puts` wait leaves the delete log + // green while C0/H1/H2 above — which already anchor on the reload — go red. + // `label` and not `name` because the name is unchanged by a rename: only + // the relabelled row is a value the pre-reload state cannot answer with. + await waitFor(() => expect(managerProps!.objects.map((o) => o.label)).toEqual(['Renamed'])); expect(deletes).toEqual([]); expect(shownError()).toBeNull(); }); From 7d28f50973a08ca5fb8405790560cf2f0ba2b0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:20:36 +0000 Subject: [PATCH 2/5] chore(scripts): keep objectui#8690's recorder-wait census as a script The detector answers where to look, never what is wrong, so it is a census tool and NOT wired into CI: a gate on this list would institutionalise the batch repair the card exists to prevent. Two recorder-matching modes, because the choice moves the numbers, and the header records what each one measured on the card's base commit rather than quoting the card's counts as reproduced. Changeset declares an empty frontmatter: test-only plus one repo script, nothing published moves. objectui#8690 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .changeset/issue-8690-recorder-wait-audit.md | 9 ++ scripts/census-recorder-wait-shape.mjs | 109 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 .changeset/issue-8690-recorder-wait-audit.md create mode 100644 scripts/census-recorder-wait-shape.mjs diff --git a/.changeset/issue-8690-recorder-wait-audit.md b/.changeset/issue-8690-recorder-wait-audit.md new file mode 100644 index 0000000000..99df370023 --- /dev/null +++ b/.changeset/issue-8690-recorder-wait-audit.md @@ -0,0 +1,9 @@ +--- +--- + +Test-only (plugin-designer) plus one repo script. The `MetadataObjectsPage.lookupKeying` +round-trip pin now reads its "no delete was issued" assertion after the page's +`reload()` — the handler's last statement — instead of after the first PUT, so the +emptiness is dated to the end of the write sequence rather than to its start. Adds +`scripts/census-recorder-wait-shape.mjs`, the objectui#8690 corpus detector. No +published behaviour changes. diff --git a/scripts/census-recorder-wait-shape.mjs b/scripts/census-recorder-wait-shape.mjs new file mode 100644 index 0000000000..6a6906ac2a --- /dev/null +++ b/scripts/census-recorder-wait-shape.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Census: `await waitFor(...)` keyed on ONE recorder array, followed by a read + * of a DIFFERENT recorder array, with nothing establishing the second was + * filled. The shape objectui#8688 was one instance of; the corpus reading is + * objectui#8690, and this file is that card's detector, kept so the next person + * does not have to re-derive it. + * + * ⚠️ It answers WHERE TO LOOK, never WHAT IS WRONG. A flag is a site to read, + * 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 + * + * 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. + * The two modes' strict buckets differ and neither contains the other: `ident` + * misses a wait written `host.calls` (the receiver hides the recorder), `path` + * misses a recorder pushed as a bare `inits` and read as `host.inits[0]`. + * objectui#8690's nine sites are inside the UNION of the two, minus + * `packages/permissions` (which objectui#8688 / PR #8689 already hold). + * + * Usage: node scripts/census-recorder-wait-shape.mjs [--recorder-match=ident|path] + */ +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +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); +} + +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], + }); + break; // one flag per (wait, recorder) + } + } + } +} + +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('--- 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}`); +} From 3778170a71e2c32974a1b61bfb45b86addb89376 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:43:13 +0000 Subject: [PATCH 3/5] chore(scripts): record what the recorder-wait census's two matchers disagree about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census script's numbers move with an internal choice its header stated but never measured. objectui#8703 measured it, and the answer is wider than the mode choice: - The two `--recorder-match` modes are incomparable by construction — each flags a shape the other cannot see (forced on fixtures, both directions). - On da5e4f69e the buckets are nevertheless nested: ident's 15 is a subset of path's 18, ident-only is empty. "They do not contain each other" is an in-principle claim, false as a measurement of this tree. - The header's stated `path` blind spot (pushed bare, read as `host.inits[0]`) is wrong: both modes miss that shape. - Two mode-independent rules dominate the mode choice: the forward window ends at the next `await` in the FILE rather than at the end of the enclosing test, and any textual occurrence counts as a read. Seven of the eighteen strict flags on that tree point at a declaration, a destructuring, a reset or a function parameter — not at a read. Header rewritten to carry that, plus a caveat printed next to the counts. The detector's behaviour is unchanged: both modes print byte-identical site lists. Script and changeset only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...ue-8703-matcher-dependent-strict-bucket.md | 10 +++ scripts/census-recorder-wait-shape.mjs | 66 +++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 .changeset/issue-8703-matcher-dependent-strict-bucket.md diff --git a/.changeset/issue-8703-matcher-dependent-strict-bucket.md b/.changeset/issue-8703-matcher-dependent-strict-bucket.md new file mode 100644 index 0000000000..a5e962b472 --- /dev/null +++ b/.changeset/issue-8703-matcher-dependent-strict-bucket.md @@ -0,0 +1,10 @@ +--- +--- + +Repo script only. `scripts/census-recorder-wait-shape.mjs` now records, in its header, +what its two recorder-matching modes actually disagree about (objectui#8703): the modes +are incomparable by construction, their strict buckets are nevertheless nested on the +current tree, and two mode-independent rules — the forward window ending at the next +`await` in the FILE, and every textual occurrence counting as a "read" — put seven +non-reads in a strict bucket of eighteen. It also prints a caveat next to its own +counts. No published behaviour changes. diff --git a/scripts/census-recorder-wait-shape.mjs b/scripts/census-recorder-wait-shape.mjs index 6a6906ac2a..3732191fb7 100644 --- a/scripts/census-recorder-wait-shape.mjs +++ b/scripts/census-recorder-wait-shape.mjs @@ -27,10 +27,63 @@ * 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. - * The two modes' strict buckets differ and neither contains the other: `ident` - * misses a wait written `host.calls` (the receiver hides the recorder), `path` - * misses a recorder pushed as a bare `inits` and read as `host.inits[0]`. - * objectui#8690's nine sites are inside the UNION of the two, minus + * + * --------------------------------------------------------------------------- + * 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. + * --------------------------------------------------------------------------- + * + * A recorder's identity 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] @@ -103,6 +156,11 @@ 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}`); From 925e07a04f6b441e91af4063b73e871df8228ff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:58:50 +0000 Subject: [PATCH 4/5] chore(scripts): give the recorder-wait census an AST matcher, and its fixtures a home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8704. The census resolved a recorder by the SPELLING of its `.push(` site, ended its forward window at the next textual `await` IN THE FILE, and counted every textual occurrence as a read. Seven of the eighteen strict flags it reported at da5e4f69e were therefore a declaration, a destructuring, a reset or a function parameter — and the same truncation went blind on a genuine cross-recorder read one ordinary `await` further on. The default matcher is now an AST pass: - identity over bindings and aliases, not names, one hop through a helper's parameter and a same-file factory's return value; - windows scoped to the enclosing test body, in statements, ending at the next awaited SETTLING anchor rather than at any `await`; - every occurrence classified read / write / declaration. The original census is kept behind `--matcher=regex`, so objectui#8690's and objectui#8703's published numbers stay reproducible from this file rather than being claims about a deleted script. Verified: at da5e4f69e it still prints 159 / 15 strict for `ident` and 167 / 18 strict for `path`, exactly as published. objectui#8703's five throwaway fixtures are committed as the script's test suite, with the sixth objectui#8704 asked for — the absence read objectui#8690 repaired, which must STAY flagged, because "flag nothing" is strictly worse than the bug and passes every other case. Both matchers are pinned over the same six files, so the repair is pinned as a direction. The header's "no count printed here is a corpus fact" caveat STAYS, with its residuals restated: no type checker, a window rule that is a judgement rather than a fact, and a flag that is still a site to read and not a defect. The census remains out of CI; the pin test runs the matcher over the fixtures only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .changeset/issue-8704-census-matcher-ast.md | 12 + .../census-recorder-wait-shape.test.ts | 152 +++ .../f1-member-push-member-read.fixture.ts | 20 + .../f2-member-push-bare-read.fixture.ts | 22 + .../f3-bare-push-member-read.fixture.ts | 23 + .../f4-window-crosses-test.fixture.ts | 57 ++ .../f5-window-truncated.fixture.ts | 24 + ...-absence-read-must-stay-flagged.fixture.ts | 30 + .../census-recorder-wait-shape/harness.ts | 30 + scripts/census-recorder-wait-shape.mjs | 955 +++++++++++++++--- 10 files changed, 1189 insertions(+), 136 deletions(-) create mode 100644 .changeset/issue-8704-census-matcher-ast.md create mode 100644 scripts/__tests__/census-recorder-wait-shape.test.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/f1-member-push-member-read.fixture.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/f2-member-push-bare-read.fixture.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/f3-bare-push-member-read.fixture.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/f4-window-crosses-test.fixture.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/f5-window-truncated.fixture.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/f6-absence-read-must-stay-flagged.fixture.ts create mode 100644 scripts/__tests__/fixtures/census-recorder-wait-shape/harness.ts 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..2ca53244c4 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,833 @@ * 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. + * 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. + * + * --------------------------------------------------------------------------- + * 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. + * 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. + * --------------------------------------------------------------------------- + * + * The three error sources objectui#8704 names are gone, and the six fixtures + * prove it. 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. + * + * 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. + * 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 alone makes 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'; + +// --------------------------------------------------------------------------- +// 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); + }, + }; +} + +/** + * 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; + } + }; -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); + /** 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); } -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], +/** + * @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 the three known\n' + + ' error sources (name-matching, runaway windows, declarations read as\n' + + ' reads); R1-R3 in this file\'s header are what is left, and a flag is\n' + + ' still a 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(); From 063a9753402478b6ca426df2d392f87f7a015426 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:19:24 +0000 Subject: [PATCH 5/5] chore(scripts): resolve the census header claim by claim, not file by file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge before this one took this branch's side of an add/add conflict and proved that drops no BYTE main carries. It does not prove it drops no CLAIM: the header main landed makes five, and each needed a verdict against the AST matcher that now sits underneath it. Four survived the rewrite. One had not. RESTORED — the two regex modes' strict buckets are NESTED on this tree. The rewrite kept "the modes are incomparable by construction" (M1) and dropped the measurement that says the union of the two is just `path`'s bucket. M1 is about shapes; on this tree ident-only is EMPTY, so a reader could take M1 as a reason to run both modes and union them, which buys nothing. Re-measured on both trees rather than copied forward: 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 the same three `PermissionMatrixEditor` sites are the path-only ones in both. The companion claim — that the mode choice was never the largest source of movement — is restored as arithmetic the file can be re-run to check: the mode separates THREE sites, D1 and D2 separate SIXTEEN (−7/+9, both verified by diffing the site lists at da5e4f69e). ADDED — R2', the half of D1 that did NOT get repaired. The window is scoped to the enclosing FUNCTION body. That kills the runaway half (it can never reach the next `it`) and leaves the truncation half alive, pointed the other way: a wait hosted in a helper the test awaits covers none of the CALLER's statements. Forced on three probes — the same cross-recorder read draws a flag with the wait inline and ZERO with it moved into a helper. Not a corner: 503 of this tree's 3945 `await waitFor(...)` sites have their window owned by an inner function, 464 of those a helper. The regex matcher was blind here too, differently, so this is a surviving blind spot rather than a regression — the header says which, and says the one shape where the old matcher's runaway window did reach the read and this one does not. The banner the reader running the script actually sees said the three error sources were removed. Two were, plus half of the third; it now says that. Also recorded: the counts move with the TREE alone. Merging main moved the population 2776 → 2786 and every strict bucket down (AST 20 → 18, path 18 → 15, ident 15 → 12) with the matcher byte-identical, because #8707/#8711/#8713 each anchored a wait. That is claim 4 demonstrating itself. Unchanged and re-verified: the census stays OUT of CI. No workflow and no package script runs it; the only caller is the pin test, over six fixtures. objectui#8704 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- scripts/census-recorder-wait-shape.mjs | 64 ++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/scripts/census-recorder-wait-shape.mjs b/scripts/census-recorder-wait-shape.mjs index 2ca53244c4..c133705f8e 100644 --- a/scripts/census-recorder-wait-shape.mjs +++ b/scripts/census-recorder-wait-shape.mjs @@ -47,7 +47,9 @@ * 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. + * 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 @@ -61,6 +63,26 @@ * 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) * --------------------------------------------------------------------------- @@ -92,6 +114,8 @@ * 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 @@ -109,8 +133,9 @@ * ⛔ THE CAVEAT STAYS. A COUNT PRINTED HERE IS STILL NOT A CORPUS FACT. * --------------------------------------------------------------------------- * - * The three error sources objectui#8704 names are gone, and the six fixtures - * prove it. Measured over the same tree the earlier numbers were read on + * 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) @@ -123,6 +148,12 @@ * 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: * @@ -141,6 +172,22 @@ * 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 @@ -151,7 +198,7 @@ * * ⇒ 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 alone makes it matcher-dependent, and a gate on this list would + * 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. @@ -833,10 +880,11 @@ function main() { 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 the three known\n' - + ' error sources (name-matching, runaway windows, declarations read as\n' - + ' reads); R1-R3 in this file\'s header are what is left, and a flag is\n' - + ' still a site to READ, never a defect. ⛔ Not a gate.' + ? '⚠️ 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.',