From 72ac6e6c3531f95e0d6f5fad96b988dc15248e2e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:16:08 +0000 Subject: [PATCH 1/2] test(hooks): pin the ctx-shape scan surface to the call sites it judges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test/hook-input-shape.test.ts` rules A and B walk every `.ts` under `test/` and key on one token — a hook handler being INVOKED, `handler(`. The file already guards its detector with planted probes, but nothing in it claimed the scanned tree still holds a call site for that detector to read. Measured on this branch's base 965933b: 175 real `.ts` files in the surface, of which 31 call a handler directly and 13 already reach a hook through `runHookBody(...)` instead — a form neither rule matches. The class these rules discriminate within is migrating out of the surface while the surface stays 175 real files, so a file count could not report it. That is #1755's shape one tree over: a real member that cannot carry the thing keeps the rule green. Strengthened in place, inside the existing self-test block — no new `it`, no new gate, the file stays at 16 tests (#1778's shape). The invocation pattern is single-sourced as `CALLS_HANDLER` so rule B and the surface check cannot come to discriminate different classes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj --- test/hook-input-shape.test.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/hook-input-shape.test.ts b/test/hook-input-shape.test.ts index df899d8d4..0ab6129c9 100644 --- a/test/hook-input-shape.test.ts +++ b/test/hook-input-shape.test.ts @@ -382,6 +382,15 @@ describe('no test may hand a hook handler a plain-object ctx (#1298)', () => { return code.slice(open); }; + /** + * A hook handler being INVOKED — the call site both rules key on. + * + * Single-sourced because rule B and the surface check in the self-test have + * to discriminate the same class: a cross-reference that proves the presence + * of something other than what is judged proves nothing. + */ + const CALLS_HANDLER = /(? { const offenders: string[] = []; for (const { file, raw } of sources) { const code = codeOnly(raw); - if (!/(? { // The scan must read CODE, not prose: this very file documents the bad form. expect(codeOnly("const s = 'handler({ event: 1 })';")).not.toContain('event'); + + // ...and the SURFACE still carries what the detector is tuned to read. The + // probes above prove the detector on planted text; nothing above proves the + // scanned tree holds a single call site for it to read, and a file COUNT + // would not prove it either — 175 real `.ts` files under `test/` stay 175 + // real files whichever way they invoke a hook. + // + // The gap is not hypothetical here. 13 files already reach a hook through + // `runHookBody(...)` instead of `handler(...)`, a form neither rule matches; + // the class these rules discriminate within is actively migrating out of + // the surface. Finish that migration and rules A and B both report clean + // over a real, non-empty tree that carries nothing they can judge — the + // #1755 shape, one tree over, where a real file that could not carry the + // thing kept a rule green for the whole life of the bug. Measured on this + // branch's base: 31 files still call a handler directly. + const callers = sources.filter(({ raw }) => CALLS_HANDLER.test(codeOnly(raw))); + expect( + callers.length, + 'no file in the scanned tree invokes a hook handler at all, so rules A and B are ' + + 'reading past every hook invocation in the repo and reporting clean over it. The ' + + 'tree has moved to another invocation form — teach both rules that form; ⛔ do not ' + + 'delete them, and ⛔ do not widen the walk to make this number come back.', + ).toBeGreaterThan(0); }); }); From c453f1167fcb50b446f8ff00732dbd4686bd0197 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:17:51 +0000 Subject: [PATCH 2/2] chore(changeset): record the content-level scan-surface sweep Test-only; empty frontmatter is the sanctioned "releases nothing" declaration the Changeset Check gate documents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj --- .../ctx-shape-scan-pins-its-call-sites.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .changeset/ctx-shape-scan-pins-its-call-sites.md diff --git a/.changeset/ctx-shape-scan-pins-its-call-sites.md b/.changeset/ctx-shape-scan-pins-its-call-sites.md new file mode 100644 index 000000000..2eeda64de --- /dev/null +++ b/.changeset/ctx-shape-scan-pins-its-call-sites.md @@ -0,0 +1,50 @@ +--- +--- + +Test-only — this PR releases nothing to HotCRM users, so the frontmatter above is +deliberately empty (the sanctioned "releases nothing" declaration that +`.github/workflows/changeset-check.yml` documents, on par with the +`skip-changeset` label). Nothing under `src/` changed: no object, field, view, +label, page, hook or translation string. The one file this PR touches is +`test/hook-input-shape.test.ts`. + +`#1778` repaired a guard that scanned a file which could not contain the thing it +looked for, and stayed green for the whole life of the bug. This is the content- +level sweep that followed it: every scan surface under `test/**` re-read against +the stronger question — not "does the walk see a non-empty set?" but "does the +assertion pin that the surface still CARRIES the class the rule matches?" + +The farm answered well. Of the surfaces measured, all but one already pin their +content, and several were stronger than expected: `hook-write-shape.test.ts` was +believed blind on the same evidence shape and turned out to carry an equality +cross-reference between its scanned call sites and the writes its runtime half +exercises, which fires on the mutation that was supposed to expose it. That +falsification is recorded here because it is the finding, not a detour: a guard +that looks count-only can be pinned by a rule three blocks away. + +The one that was not pinned is `test/hook-input-shape.test.ts`. Its two rules +walk every `.ts` under `test/` and key on one token — a hook handler being +INVOKED, `handler(`. The file already guards its DETECTOR with planted probes, +but nothing in it claimed the scanned tree still holds a call site for that +detector to read, and a file count could not have claimed it either: 175 real +files stay 175 real files whichever way they invoke a hook. + +That gap is live rather than theoretical. Measured on this branch's base: 31 +files call a handler directly and 13 already reach a hook through +`runHookBody(...)`, a form neither rule matches. The class these rules +discriminate within is migrating out of the surface. Finish that migration and +both rules report clean over a real, non-empty tree carrying nothing they can +judge — `#1755`'s shape, one tree over. + +Proved rather than argued, three readings against one mutation. With a hand-built +plain-object ctx planted in the tree, the shipped guard exits 1 and names it, so +the defect is real and detectable. With the surface swapped to a tree that is +real and non-empty but carries no call site (44 files, 0 invocations), the old +assertions exit 0 with 16 passed — the defect live, the guard silent. The new +assertion exits 1 on the same mutation and names file and line. + +No new `it`, no new gate: the assertion went into the existing self-test block, +and the file stays at 16 tests. The invocation pattern is single-sourced so the +rule and the surface check cannot come to discriminate different classes. No +surface was widened, and no check was added to a surface that already pins its +content.