Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/ctx-shape-scan-pins-its-call-sites.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 33 additions & 1 deletion test/hook-input-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /(?<![\w$])handler\s*\(\s*[^)\s]/;

const HAS_EVENT = /(^|[{,\s])event\s*:/;
const ROUTED_INPUT = /(^|[^\w$])input\s*:\s*(?:engineFlatInput|makeCtx)\s*\(/;
const IMPORTS_HARNESS = /from\s+'(?:\.\.?\/)*(?:helpers\/)?hook-harness'/;
Expand Down Expand Up @@ -423,7 +432,7 @@ describe('no test may hand a hook handler a plain-object ctx (#1298)', () => {
const offenders: string[] = [];
for (const { file, raw } of sources) {
const code = codeOnly(raw);
if (!/(?<![\w$])handler\s*\(\s*[^)\s]/.test(code)) continue;
if (!CALLS_HANDLER.test(code)) continue;
if (IMPORTS_HARNESS.test(raw) && USES_ROUTER.test(code)) continue;
offenders.push(file);
}
Expand Down Expand Up @@ -468,5 +477,28 @@ describe('no test may hand a hook handler a plain-object ctx (#1298)', () => {

// 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);
});
});
Loading