Found while implementing #15636 (the 23-reader sweep); filed as a finding only, not claimed.
#15552 re-pointed the stack.objects readers onto the shared, guarded recordsOf; #15636 re-pointed 22 more. Sixteen modules in packages/lint/src still declare a PRIVATE copy of the same coercion. None of them crashes today, which is exactly why they are worth a card: they are the mechanism that produced the defect, not the defect.
Measured
On 1c1421401, packages/lint/src held 40 module-level declarations of the shape (v: unknown) returning AnyRec[] — one canonical (recordsOf in object-graph.ts) and 39 copies. After #15636 lands there are 17 copies. One of those (validate-chart-bindings.ts) is deferred to #15575, which owns that file. The other sixteen are this finding:
Twelve carry the function form with the array-branch filter added LOCALLY, in two different spellings (v.filter((x): x is AnyRec => isRec(x)) and v.filter((x): x is AnyRec => !!x && typeof x === 'object')):
validate-action-body-writes.ts, validate-ai-agent-authoring.ts, validate-ai-surface-affinity.ts, validate-ai-tool-references.ts, validate-flow-node-writes.ts, validate-hook-body-writes.ts, validate-nav-object-servability.ts, validate-nav-target-refs.ts, validate-page-visualization-bindings.ts, validate-readonly-action-writes.ts, validate-rule-compilability.ts, validate-view-page-refs.ts
Four carry an arrow form that handles ONLY the list shape and casts it unchecked — const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); — reading stack.pages:
validate-jsx-pages.ts, validate-page-source-styling.ts, validate-react-page-props.ts, validate-react-pages.ts
Why the four page walks do not throw today
Each guards at the CALL SITE rather than at the reader: const page = pages[p]; if (!page || page.kind !== 'react') continue;. A null member is skipped by !page, and 42 / 'x' / [] all read .kind as undefined and are skipped too. So the crash class is closed for them — by a guard three lines downstream of the unchecked cast, in four independent places, none of which the reader itself promises. Move the guard back into the reader and the four call-site checks become belt-and-braces instead of load-bearing.
The map shape is also silently unsupported in those four: a pages: authored as a name-keyed map reads as an empty list, so every page lint passes vacuously. recordsOf handles both shapes, so re-pointing closes that too — worth confirming against the page schema before assuming it is a bug rather than a deliberate narrowing.
Suggested shape
Delete the sixteen copies and re-point their call sites onto recordsOf from ./object-graph.js, the same edit #15552 and #15636 made. Then delete the corresponding rows from COPY_LEDGER (and, for the four arrow copies, from UNGUARDED_ALLOWANCE) in packages/lint/src/collection-coercion-single-copy.test.ts — that test is exact in both directions, so it fails while a row names a file that no longer holds a copy, and the cleanup is not finished until the rows are gone.
Context
Generated by Claude Code
Found while implementing #15636 (the 23-reader sweep); filed as a finding only, not claimed.
#15552 re-pointed the
stack.objectsreaders onto the shared, guardedrecordsOf; #15636 re-pointed 22 more. Sixteen modules inpackages/lint/srcstill declare a PRIVATE copy of the same coercion. None of them crashes today, which is exactly why they are worth a card: they are the mechanism that produced the defect, not the defect.Measured
On
1c1421401,packages/lint/srcheld 40 module-level declarations of the shape(v: unknown) returning AnyRec[]— one canonical (recordsOfinobject-graph.ts) and 39 copies. After #15636 lands there are 17 copies. One of those (validate-chart-bindings.ts) is deferred to #15575, which owns that file. The other sixteen are this finding:Twelve carry the
functionform with the array-branch filter added LOCALLY, in two different spellings (v.filter((x): x is AnyRec => isRec(x))andv.filter((x): x is AnyRec => !!x && typeof x === 'object')):validate-action-body-writes.ts,validate-ai-agent-authoring.ts,validate-ai-surface-affinity.ts,validate-ai-tool-references.ts,validate-flow-node-writes.ts,validate-hook-body-writes.ts,validate-nav-object-servability.ts,validate-nav-target-refs.ts,validate-page-visualization-bindings.ts,validate-readonly-action-writes.ts,validate-rule-compilability.ts,validate-view-page-refs.tsFour carry an arrow form that handles ONLY the list shape and casts it unchecked —
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);— readingstack.pages:validate-jsx-pages.ts,validate-page-source-styling.ts,validate-react-page-props.ts,validate-react-pages.tsWhy the four page walks do not throw today
Each guards at the CALL SITE rather than at the reader:
const page = pages[p]; if (!page || page.kind !== 'react') continue;. Anullmember is skipped by!page, and42/'x'/[]all read.kindasundefinedand are skipped too. So the crash class is closed for them — by a guard three lines downstream of the unchecked cast, in four independent places, none of which the reader itself promises. Move the guard back into the reader and the four call-site checks become belt-and-braces instead of load-bearing.The map shape is also silently unsupported in those four: a
pages:authored as a name-keyed map reads as an empty list, so every page lint passes vacuously.recordsOfhandles both shapes, so re-pointing closes that too — worth confirming against the page schema before assuming it is a bug rather than a deliberate narrowing.Suggested shape
Delete the sixteen copies and re-point their call sites onto
recordsOffrom./object-graph.js, the same edit #15552 and #15636 made. Then delete the corresponding rows fromCOPY_LEDGER(and, for the four arrow copies, fromUNGUARDED_ALLOWANCE) inpackages/lint/src/collection-coercion-single-copy.test.ts— that test is exact in both directions, so it fails while a row names a file that no longer holds a copy, and the cleanup is not finished until the rows are gone.Context
stack.objectsandrecordsOf.validate-chart-bindings.tsbelongs to chart-measure-unknown errors on report/list/page chart series[].name with a message that names an empty series — at the pin it is a display-name override that lands on nothing #15575, not to this card.Generated by Claude Code