fix(lint): the remaining collection readers use the guarded recordsOf instead of private copies (#15728) - #16286
Conversation
…arded readers Sixteen modules in `packages/lint/src` still declared a private `(v: unknown) => AnyRec[]` collection coercion. Twelve of them (the `function` form, which already filtered non-records locally in two spellings) now read `recordsOf` from `object-graph.js`. The four page walks (the arrow form, which cast the array branch unchecked and read only the list shape) now read `collectionEntries` from `collection-entries.js`, which makes the same drop inside the reader while carrying each survivor's real config path — `recordsOf` would renumber the positional `pages[N].source` paths those four emit, which is #15740's open question and not this change's to answer. Their sixteen `COPY_LEDGER` rows and the four `UNGUARDED_ALLOWANCE` rows are deleted with them; the gate is exact in both directions, so it was red on the stale rows until they went. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…tisfied `non-record-object-entry.test.ts` recorded one phantom finding still invented for `stack.agents · an array`; `recordsOf` filters with `isRec`, so the row is now a lie and comes out. `validate-rule- compilability.test.ts` excused the receiver `v`, which only existed inside the private `asArray` this change deleted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
`pages` is authorable as a name-keyed map — `MAP_SUPPORTED_FIELDS` lists it and `normalizeStackInput` folds it into a list before the schema parses it, which is why `stack.zod.ts` declares only the post-normalization `z.array(PageSchema)`. These four rules run on the raw `os lint` path, where nothing has normalized anything, and their private coercion answered a map with `[]` — so every page lint passed by never running. Pins the closure per rule with a specific rule id and the finding path, plus the list carrier as the positive control. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
📓 Docs Drift Check24 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 01c80739320db8f56c355c57750078f69a40f736 && git checkout 01c80739320db8f56c355c57750078f69a40f736
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 173afb20e95e68bad79f20b5b45433912624da3a && git checkout -B drift-repro d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 && git merge --no-ff 173afb20e95e68bad79f20b5b45433912624da3a
node scripts/docs-audit/affected-docs.mjs --json d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 |
Part of #15728
The sixteen modules in
packages/lint/srcthat still declared a private(v: unknown) => AnyRec[]collection coercion now read the package's shared,guarded readers, and their sixteen
COPY_LEDGERrows plus the fourUNGUARDED_ALLOWANCErows are deleted with them.Part of, not a closing keyword, for one reason stated below under "Why twoledger rows stay": two rows survive in
COPY_LEDGER, and they belong to thestill-open #15740, not to this card. Everything #15728 itself names is done —
all sixteen of its rows are gone — so the PM can retire the card by hand on
merge; this body does not ask GitHub to do it.
Population, re-derived from the ledger
The card names sixteen modules. The PM brief named eighteen — the ledger's row
count on
origin/mainat dispatch. Re-derived on the branch base6a1e38244:Of those 18, 16 are this card's and 2 are #15740's. The ledger says so
itself, per row: both
validate-object-field-refs.tsandvalidate-list-view-field-refs.tscarry the value'#15740'and a noterecording that re-pointing them is blocked on an index-preserving reader.
validate-chart-bindings.tsis not in the ledger at all today (#15741 took it),so there was nothing to steer around and it is untouched.
Per-file classification and destination
Twelve carried the
functionform with the non-record filter already addedlocally, in two spellings. They read
recordsOffrom./object-graph.js:recordsOfvalidate-action-body-writes.tsfilter(x => isRec(x)){ name, ...(isRec(def) ? def : {}) }validate-flow-node-writes.tsfilter(x => isRec(x))validate-hook-body-writes.tsfilter(x => isRec(x))validate-readonly-action-writes.tsfilter(x => isRec(x))validate-nav-object-servability.tsfilter(isRec)isRec(def) ? { name, ...def } : { name }validate-nav-target-refs.tsfilter(isRec)validate-view-page-refs.tsfilter(isRec)validate-page-visualization-bindings.tsfilter(isRec){ name, ...(isRec(def) ? def : {}) }validate-ai-agent-authoring.tsfilter(x => !!x && typeof x === 'object'){ name, ...(def as AnyRec) }isRecdrops itvalidate-ai-surface-affinity.tsvalidate-ai-tool-references.tsvalidate-rule-compilability.tsfilter(isRec).filter(([, def]) => isRec(def))— drops the entryrecordsOfkeeps it as{ name }Four carried the arrow form, which cast the array branch unchecked and handled
only the list shape. They read
collectionEntriesfrom./collection-entries.js, notrecordsOf— see the next section:validate-jsx-pages.ts,validate-page-source-styling.ts,validate-react-page-props.ts,validate-react-pages.ts.The two divergences above are behaviour changes only on already-malformed
input, and both are the canonical reader's answer rather than a new one. The
first is measured in this diff:
non-record-object-entry.test.tsrecorded'stack.agents · an array': 1as a phantom finding still invented at aposition nobody authored, and that row is now deleted because the assertion
went red demanding an invention that no longer happens. The second reaches no
check that reads it — a
{ name }-only validation rule matches neithertype === 'format'nortype === 'json_schema'.Why the four page walks take
collectionEntriesinsteadAll four emit a positional finding path,
pages[N].source, consumed as an edittarget. Their private copy dropped nothing, so
Nwas the author's real index.recordsOfdrops a non-record member, so apages:list with an empty itemwould have renumbered every path after it and pointed an editor at the wrong
page. That is exactly the collision #15740 documents, and #15740 states in
terms that it needs a maintainer to settle "before anybody re-points these two
(or the sixteen in #15728, several of which build positional paths the same
way)".
collectionEntries(collection-entries.ts, from #6662) makes the same dropinside the reader — which is what the triage asked for, so the downstream
if (!page || page.kind !== 'react') continue;lines are now belt-and-bracesand are kept — while carrying each survivor's real config path. It is already
the in-package idiom for exactly this:
validate-translatable-sections.tsandvalidate-visibility-predicates.tsboth walkstack.pagesthrough it. So nopositional path moves,
recordsOfis not widened, and #15740's three optionsare all still open. The gate counts the coercion SHAPE, so either destination
satisfies it; the gate's docblock now says which reader a rule should take and
why.
The map-shaped
pages:reading — it IS authorableThe triage asked for this to be confirmed against the schema before being
treated as a bug. It resolves the first way, and the schema line alone is
misleading:
packages/spec/src/stack.zod.ts:370—pages: z.array(PageSchema).optional().describe('Custom Pages'), insideSTACK_DEFINITION_COLLECTIONS_SHAPE(line 270). Read alone this says "a map is not authorable", and a baresafeParseof a map agrees:invalid_type: Invalid input: expected array, received object.packages/spec/src/shared/metadata-collection.zod.ts:74declaresMAP_SUPPORTED_FIELDS, "collections whose map/record authoring form is normalized into an array", and'pages'is on it at line 77.normalizeStackInput(same file) folds the map into a list, injecting the map key asname, before Zod validation.Measured on the built spec:
So a name-keyed
pages:map is an authoring carrier. These four rules are pure(stack) => Finding[](ADR-0019) and run on the RAWos lintpath wherenothing has normalized anything, and their coercion answered a map with
[].Every page lint therefore passed on a map-shaped stack by never running: an
empty source, a syntax error, an unparseable component and a Tailwind
classNameall read as clean. The changeset states this as the bug it is(
patch).packages/lint/src/page-walk-map-shape.test.tspins the closure per rule witha specific rule id and the finding path —
react-page-empty-source,jsx-page-empty-source,page-source-className-tailwind,react-page-source-unparseable, each atpages.home.source— plus the listcarrier at
pages[0].sourceas a positive control.Ablation. Restoring the private arrow copy in
validate-react-pages.tsandwalking it positionally turns the map case red with the vacuity signature
(
AssertionError: expected [] to include 'react-page-empty-source') while thelist-carrier control stays green (1 failed, 4 passed). The mutation was proved
on disk in both directions before the run (
asArrayABLATEDpresent, zerosurviving
collectionEntries(stack.pages, 'pages')calls), and the restore isbyte-identical to
HEADbygit hash-object(66f2a8202b8a…), tree clean.The one-copy gate, before and after
Run once before the ledger rows were removed, as the positive control. It
named exactly the expected sets, in both directions, and named neither of
#15740's two rows:
After removing 16
COPY_LEDGERrows and 4UNGUARDED_ALLOWANCErows:green, as part of the whole-package run below.
Why two ledger rows stay
validate-object-field-refs.tsandvalidate-list-view-field-refs.tskeeptheir copies and their rows in both lists. The ledger's own note is the reason:
reference-integrity-suite.test.tspins their finding paths(
objects[1].highlightFields[1]) against the author's file, and a reader thatdrops a member renumbers past it. #15740 is open and
pm:awaiting-maintainerover exactly that question. Neither file is touched here.
Worth passing back to #15740: option 2 in that card asks for "an
index-preserving companion in
object-graph.ts". One already exists in thepackage —
collectionEntries— and this PR uses it for four readers with thesame positional-path constraint. That does not decide #15740, whose two readers
pin an
objects[N]…path shape rather than a page path, but it is evidenceoption 2 is cheaper than it looked.
Verification, pinned to
173afb20epnpm --filter @objectstack/lint testpnpm --filter @objectstack/lint typechecktsc --noEmit+check:test-typecheck: OK)pnpm --filter '@objectstack/lint^...' buildpnpm lint(eslint . --no-inline-config, repo-wide)node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack--ranreconciliation54 derived famil(ies) accounted for — 54 run, 0 NOT-MEASURED, 0 UNRUNThe two NOT MEASURED are
PREREQUISITE NOT MET, exit 3 — the gates' own"nothing was measured" code, not a finding:
pnpm check:dual-build-cjs-loadsand
pnpm check:type-check-debtboth want a whole-repodist/, which CIbuilds.
pnpm check:docs-transcript-driftwas in that state too and is nowgreen — it reads
@objectstack/lint's built registry, so it was worth buildingthis package to get a real verdict on it:
Gate exit codes were captured before any pipe.
Declared, not run: the branch base is
6a1e38244andorigin/mainhas movedsince; CI judges the merge ref. The gate derivation warned that two of the
family scripts it read have changed on
mainin that window(
check-changeset-no-major.mjs,check-type-source-resolution.mjs) — bothgreen here, both re-run by CI from their current copies.
Scope
@objectstack/lintis published, so.changeset/lint-collection-copies-guarded-readers.md(patch, bug class).No
content/docs/releases/edit.object-graph.tsis unchanged. PR #15791(card #15742) holds
validate-expressions.tsand its tests — not touched here.Non-closing references throughout: #15636, #15552, #15575, #15740, #15742,
#15494, #15741, #6662.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Generated by Claude Code