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/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(); }); 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}`); +}