diff --git a/.changeset/handler-key-reads-follow-cast-receivers.md b/.changeset/handler-key-reads-follow-cast-receivers.md new file mode 100644 index 0000000000..892e28ba17 --- /dev/null +++ b/.changeset/handler-key-reads-follow-cast-receivers.md @@ -0,0 +1,6 @@ +--- +--- + +Internal tooling only — no package source changed, so this changeset declares "no release" rather than requesting one. + +`scripts/check-handler-key-read-sites.mjs` now reads a property access through the type-only wrappers that erase at runtime (`as`, `satisfies`, `!`, parentheses), so `(schema as any).onX` is judged exactly as `schema.onX` already was. A cast was never one of the five boundaries that gate declares it does not answer; it was an undeclared hole, and two live `onTabChange` reads sat in it (objectui#9344). Their per-key disposition is not decided here — both are ledgered to objectui#7804, which owns it. diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index 5b2d669ff1..2425dc5edb 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -414,6 +414,140 @@ ComponentRegistry.register('button', ({ schema }: any) => { + // ⚠️ The fixture key is `onTabSwap`, not the live `onTabChange`, and that is + // load-bearing rather than cosmetic: `KNOWN_UNDECLARED_READS` is global, so a + // fixture naming the same `type::Schema.key` as a real ledger row is EXEMPTED + // and reports no finding. Spelled `onTabChange`, the negative control below + // went green for that reason alone — a green that says nothing about whether + // the gate can see a cast. + const behindCast = (members: string[], read: string) => ({ + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/layout.zod.ts': arm('tabs', 'TabsSchema', members), + 'packages/plugin-tabs/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +export const TabsRenderer = ({ schema }: { schema: any }) => ( + +); +ComponentRegistry.register('tabs', TabsRenderer, { namespace: 'view' }); +`, + }); + + // ⭐ THE FIRING NEGATIVE CONTROL. A handler read deliberately hidden behind a + // cast, on an arm that declares nothing, must turn the gate RED. This leg + // fails on the gate as it stood before objectui#9344 — that is its job. + it('goes RED on a handler read hidden behind an `as any` cast', () => { + const result = analyze(tree('cast-red', behindCast([], '(schema as any).onTabSwap'))); + expect( + result.findings.map((f) => `${f.kind} ${f.key}`), + 'a cast-hidden read of a key no arm declares must be a FINDING — a green here is the ' + + 'objectui#9344 blindness, not a clean tree', + ).toEqual(['undeclared tabs::TabsSchema.onTabSwap']); + expect(result.counters.reads).toBe(1); + }); + + // The optional-chained spelling, which is how `packages/components`' layout + // `containers.tsx` writes the live one. `?.` puts the cast under a + // PropertyAccessExpression with a questionDotToken; the receiver is the same + // parenthesised cast either way, so losing one spelling and not the other + // would be a half-fix. + it('goes RED on the optional-chained cast spelling too', () => { + const result = analyze(tree('cast-red-optional', behindCast([], '(schema as any)?.onTabSwap'))); + expect(result.findings.map((f) => f.key)).toEqual(['tabs::TabsSchema.onTabSwap']); + }); + + // The other type-only wrappers that erase the same way. Each is asserted for + // the READ being seen, so a future narrowing that drops one is caught here + // rather than by the next census that quietly shrinks. + it.each([ + ['non-null assertion', 'schema!.onTabSwap'], + ['satisfies expression', '(schema satisfies any).onTabSwap'], + ['a cast under a cast', '((schema as any) as any).onTabSwap'], + ])('sees the read through a %s', (_label, read) => { + const result = analyze(tree(`cast-red-${_label.replace(/\W+/g, '-')}`, behindCast([], read))); + expect(result.findings.map((f) => f.key)).toEqual(['tabs::TabsSchema.onTabSwap']); + }); + + // ⚠️ The angle-bracket assertion is the one erasing form this gate CANNOT see, + // and that is a property of the parse rather than of the walk: `parseSource` + // hard-codes `ts.ScriptKind.TSX`, under which `schema` is JSX and the + // property access never exists. Asserted rather than left out, so the day the + // gate stops parsing as TSX this leg says what changed. + it('cannot see the angle-bracket assertion, because TSX parses it as JSX', () => { + const result = analyze(tree('cast-angle', behindCast([], '(schema).onTabSwap'))); + expect(result.findings).toEqual([]); + expect(result.counters.reads).toBe(0); + // FIRING CONTROL for that zero: the identical fixture with the `as any` + // spelling DOES produce the read, so this zero is about the parse of one + // form and not a fixture the walk never reached. + const asAny = analyze(tree('cast-angle-control', behindCast([], '(schema as any).onTabSwap'))); + expect(asAny.counters.reads).toBe(1); + expect(asAny.findings.map((f) => f.key)).toEqual(['tabs::TabsSchema.onTabSwap']); + }); + + // The control ON the negative control: the same cast-hidden read, DECLARED. + // Green here has to be a green about the declaration — so the read counter is + // asserted non-zero, because a green that walked nothing would satisfy the + // findings assertion identically. + it('stays GREEN when the arm declares the key the cast hides', () => { + const result = analyze( + tree('cast-green', behindCast([RUNTIME_SLOT('onTabSwap')], '(schema as any).onTabSwap')), + ); + expect(result.findings).toEqual([]); + expect( + result.counters.reads, + 'the green above must be a judgement on a read that was FOUND, not a walk that found none', + ).toBe(1); + expect(result.counters.judged).toBe(1); + }); + + // ⚠️ The widening is about what the gate SEES, never about what it JUDGES. + // Peeling the wrapper still leaves an identifier that has to be the document + // or the component's own props parameter — so a cast on an unrelated local + // stays invisible, exactly as the uncast form of the same read does. + it('still ignores a cast on an object that is not the document or the props', () => { + const result = analyze( + tree('cast-unrelated', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/layout.zod.ts': arm('tabs', 'TabsSchema', []), + 'packages/plugin-tabs/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +import { useToolbar } from './toolbar'; +export const TabsRenderer = ({ schema }: { schema: any }) => { + const toolbar = useToolbar(); + return ; +}; +ComponentRegistry.register('tabs', TabsRenderer, { namespace: 'view' }); +`, + }), + ); + expect(result.findings).toEqual([]); + // FIRING CONTROL for that zero: the same fixture shape DOES produce a read + // when the receiver is the document, one leg above. A zero with no such + // control would also be produced by a walk that never ran. + expect(result.counters.reads).toBe(0); + expect(result.counters.registrations).toBe(1); + expect(result.counters.armed).toBe(1); + }); +}); + describe('check-handler-key-read-sites — this repository', () => { const result = analyze(repoRoot); @@ -485,6 +619,41 @@ describe('check-handler-key-read-sites — this repository', () => { expect(chatbotSend?.disposition).toBe('runtime-slot'); }); + /** + * objectui#9344's REPRODUCTION-IS-ACCEPTANCE leg, pinned on the real tree. + * + * Two live `(schema as any).onTabChange` reads were outside this census + * entirely — not exempted, not judged, not counted. They are the measured + * instance of the cast blindness, so they are named here rather than left to a + * count: a count moves for any reason, and the reason these two moved is the + * one thing this leg exists to hold. + */ + it('counts the two cast-hidden `onTabChange` reads objectui#9344 measured', () => { + const census = (type: string, key: string) => result.census.find((c) => c.type === type && c.key === key); + + // Both are JUDGED members of the census — the state before objectui#9344 was + // absence, which no assertion about declaration could have caught. + expect(census('tabs', 'onTabChange')?.file).toBe('packages/components/src/renderers/layout/containers.tsx'); + expect(census('detail', 'onTabChange')?.file).toBe('packages/plugin-detail/src/DetailView.tsx'); + + // Neither arm declares the key, which is why both carry a ledger row. ⚠️ The + // two are NOT co-judgeable and this leg deliberately asserts nothing about + // which disposition either should get: `TabsSchema` declares a DIFFERENT + // spelling for what looks like the same event, so `'tabs'` may be an ALIAS + // question rather than a declaration one. That is objectui#9344's item ②. + expect(census('tabs', 'onTabChange')?.declared).toBe(false); + expect(census('detail', 'onTabChange')?.declared).toBe(false); + expect(KNOWN_UNDECLARED_READS.has('tabs::TabsSchema.onTabChange')).toBe(true); + expect(KNOWN_UNDECLARED_READS.has('detail::DetailSchema.onTabChange')).toBe(true); + + // FIRING CONTROL for the two `false`s: the SAME arm that fails to declare + // `onTabChange` does declare `onValueChange`, so `declared: false` above is a + // reading about that one key and not an arm the resolver failed to read. + const { arms } = collectArms(repoRoot); + expect(arms.get('tabs')?.members.has('onValueChange')).toBe(true); + expect(arms.get('tabs')?.members.has('onTabChange')).toBe(false); + }); + /** * The ledger is an EXEMPTION list, never the population, and it only shrinks. * Both directions are pinned: a row whose defect is gone reads as a live waiver diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index cef801d6a6..407acf0edf 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -193,6 +193,22 @@ export const KNOWN_UNDECLARED_READS = new Map([ ['object-gallery::ObjectGallerySchema.onCardClick', 'objectui#7804'], ['object-gallery::ObjectGallerySchema.onRowClick', 'objectui#7804'], ['object-view::ObjectViewSchema.onNavigate', 'objectui#7804'], + // ⭐ objectui#9344 — the two rows this ledger could not have held before, and + // the reason its population was never a total. Both reads are spelled + // `(schema as any).onTabChange`, and a cast receiver was invisible to the + // census until objectui#9344 taught `handlerReadsIn` to read through one. They + // are the SAME defect as every row above — a registered renderer reading a key + // its arm never declared, accepted and KEPT by the passthrough — so they name + // the same parent, which owns the per-key disposition. + // + // ⚠️ The two are NOT co-judgeable, and objectui#9344 ruled that they must not + // be disposed alike without measuring: `TabsSchema` already declares a + // DIFFERENT spelling, `onValueChange`, for what looks like the same event, so + // the `'tabs'` row may be an ALIAS question rather than a declaration one, + // while `DetailSchema` declares neither spelling. Deciding either is + // objectui#9344's item ②, which lands in the zod arms and not in this file. + ['tabs::TabsSchema.onTabChange', 'objectui#7804'], + ['detail::DetailSchema.onTabChange', 'objectui#7804'], ]); /** @@ -600,12 +616,60 @@ export function relativeImportsIn(sourceFile) { return bindings; } +/** + * The name a receiver expression denotes once every TYPE-ONLY wrapper is peeled + * off it, or `null` when what is left is not a plain identifier. + * + * A cast is erasure: `(schema as any).onTabChange` and `schema.onTabChange` emit + * the same property access on the same object, so a census that sees one and not + * the other is not describing the runtime. This gate saw only the second until + * objectui#9344 measured two live reads it had never counted — the + * `(schema as any).onTabChange` in `plugin-detail`'s `DetailView` (registered + * `detail` / `detail-view`) and the `(schema as any)?.onTabChange` in + * `components`' layout `containers` (registered `tabs`). Both are the exact + * passthrough exposure this file exists to find, and neither was among the + * boundaries above: the five things this gate declares it does not answer are + * about channels it cannot DERIVE a read site from, and a cast is not one of + * them — the read site is right there in the AST, one node deeper. + * + * Only wrappers that vanish at runtime are peeled, so this widens what the gate + * SEES without widening what it JUDGES: the identifier underneath still has to + * be `schema` or the component's own props parameter. + */ +function erasedReceiverName(expression) { + let current = expression; + // Bounded rather than `while (true)`: these nest (`((schema as any)!)`), but a + // real source never stacks them deeply, and a bound cannot loop on a cycle. + for (let hop = 0; hop < 8; hop += 1) { + if (ts.isIdentifier(current)) return current.text; + // ⚠️ The angle-bracket assertion `(schema).onX` is deliberately NOT + // here, and its absence is measured rather than assumed: `parseSource` + // hard-codes `ts.ScriptKind.TSX` for EVERY file, and under TSX `schema` + // parses as JSX — the property access does not survive the parse at all, in + // a `.ts` source as much as a `.tsx` one. A branch for it would be dead + // code, not coverage. + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + continue; + } + return null; + } + return null; +} + /** * The `schema.onX` / `.onX` property accesses inside one node. * * `schema` is the authored document as every renderer in this repository spells * it; the second half is the props parameter's own name, so a renderer written * `(props) => props.onChange(…)` counts and an unrelated local object does not. + * The receiver is read through type-only wrappers (see `erasedReceiverName`), so + * a cast does not hide a read from this census. */ export function handlerReadsIn(node) { const objects = new Set(['schema', ...propsParameterNames(node)]); @@ -613,8 +677,7 @@ export function handlerReadsIn(node) { const walk = (current) => { if ( ts.isPropertyAccessExpression(current) && - ts.isIdentifier(current.expression) && - objects.has(current.expression.text) && + objects.has(erasedReceiverName(current.expression)) && isHandlerKey(current.name.text) ) { const line = current.getSourceFile().getLineAndCharacterOfPosition(current.getStart()).line + 1;