From bf252aae0f0ebbadf8481c9a6526b0399a195ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:14:51 +0000 Subject: [PATCH 1/2] 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/2] 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}`); +}