Skip to content

fix(lint): the remaining collection readers use the guarded recordsOf instead of private copies (#15728) - #16286

Merged
baozhoutao merged 4 commits into
mainfrom
claude/issue-15728-lint-recordsof-copies
Sep 6, 2026
Merged

fix(lint): the remaining collection readers use the guarded recordsOf instead of private copies (#15728)#16286
baozhoutao merged 4 commits into
mainfrom
claude/issue-15728-lint-recordsof-copies

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Part of #15728

The sixteen modules in packages/lint/src that still declared a private
(v: unknown) => AnyRec[] collection coercion now read the package's shared,
guarded readers, and their sixteen COPY_LEDGER rows plus the four
UNGUARDED_ALLOWANCE rows are deleted with them.

Part of, not a closing keyword, for one reason stated below under "Why two
ledger rows stay": two rows survive in COPY_LEDGER, and they belong to the
still-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/main at dispatch. Re-derived on the branch base 6a1e38244:

git grep -nE "\(v: unknown\): AnyRec\[\]" -- packages/lint/src   ->  19
   = 1 canonical (`recordsOf`, object-graph.ts:181) + 18 copies

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.ts and
validate-list-view-field-refs.ts carry the value '#15740' and a note
recording that re-pointing them is blocked on an index-preserving reader.
validate-chart-bindings.ts is 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 function form with the non-record filter already added
locally, in two spellings. They read recordsOf from ./object-graph.js:

module array branch before map branch before vs recordsOf
validate-action-body-writes.ts filter(x => isRec(x)) { name, ...(isRec(def) ? def : {}) } identical
validate-flow-node-writes.ts filter(x => isRec(x)) same identical
validate-hook-body-writes.ts filter(x => isRec(x)) same identical
validate-readonly-action-writes.ts filter(x => isRec(x)) same identical
validate-nav-object-servability.ts filter(isRec) isRec(def) ? { name, ...def } : { name } identical
validate-nav-target-refs.ts filter(isRec) same identical
validate-view-page-refs.ts filter(isRec) same identical
validate-page-visualization-bindings.ts filter(isRec) { name, ...(isRec(def) ? def : {}) } identical
validate-ai-agent-authoring.ts filter(x => !!x && typeof x === 'object') { name, ...(def as AnyRec) } differs — the local filter keeps an ARRAY member; isRec drops it
validate-ai-surface-affinity.ts same same differs, same way
validate-ai-tool-references.ts same same differs, same way
validate-rule-compilability.ts filter(isRec) .filter(([, def]) => isRec(def)) — drops the entry differsrecordsOf keeps it as { name }

Four carried the arrow form, which cast the array branch unchecked and handled
only the list shape. They read collectionEntries from
./collection-entries.js, not recordsOf — 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.ts recorded
'stack.agents · an array': 1 as a phantom finding still invented at a
position 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 neither
type === 'format' nor type === 'json_schema'.

Why the four page walks take collectionEntries instead

All four emit a positional finding path, pages[N].source, consumed as an edit
target. Their private copy dropped nothing, so N was the author's real index.
recordsOf drops a non-record member, so a pages: list with an empty item
would 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 drop
inside the reader — which is what the triage asked for, so the downstream
if (!page || page.kind !== 'react') continue; lines are now belt-and-braces
and are kept — while carrying each survivor's real config path. It is already
the in-package idiom for exactly this: validate-translatable-sections.ts and
validate-visibility-predicates.ts both walk stack.pages through it. So no
positional path moves, recordsOf is not widened, and #15740's three options
are 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 authorable

The 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:370pages: z.array(PageSchema).optional().describe('Custom Pages'), inside STACK_DEFINITION_COLLECTIONS_SHAPE (line 270). Read alone this says "a map is not authorable", and a bare safeParse of a map agrees: invalid_type: Invalid input: expected array, received object.
  • That is the post-normalization form. packages/spec/src/shared/metadata-collection.zod.ts:74 declares MAP_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 as name, before Zod validation.

Measured on the built spec:

MAP_SUPPORTED_FIELDS includes 'pages': true
authored pages shape: map
after normalizeStackInput: array [{"kind":"react","label":"Home","source":"...","name":"home"}]
parse of the NORMALIZED map form: pages issues = 0

So a name-keyed pages: map is an authoring carrier. These four rules are pure
(stack) => Finding[] (ADR-0019) and run on the RAW os lint path where
nothing 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
className all read as clean. The changeset states this as the bug it is
(patch).

packages/lint/src/page-walk-map-shape.test.ts pins the closure per rule with
a specific rule id and the finding path — react-page-empty-source,
jsx-page-empty-source, page-source-className-tailwind,
react-page-source-unparseable, each at pages.home.source — plus the list
carrier at pages[0].source as a positive control.

Ablation. Restoring the private arrow copy in validate-react-pages.ts and
walking it positionally turns the map case red with the vacuity signature
(AssertionError: expected [] to include 'react-page-empty-source') while the
list-carrier control stays green (1 failed, 4 passed). The mutation was proved
on disk in both directions before the run (asArrayABLATED present, zero
surviving collectionEntries(stack.pages, 'pages') calls), and the restore is
byte-identical to HEAD by git 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:

AssertionError: 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-jsx-pages.ts,
validate-nav-object-servability.ts, validate-nav-target-refs.ts,
validate-page-source-styling.ts, validate-page-visualization-bindings.ts,
validate-react-page-props.ts, validate-react-pages.ts,
validate-readonly-action-writes.ts, validate-rule-compilability.ts,
validate-view-page-refs.ts no longer declares a private coercion.
  -> expected [ …(16) ] to deeply equal []

AssertionError: validate-jsx-pages.ts, validate-page-source-styling.ts,
validate-react-page-props.ts, validate-react-pages.ts no longer casts unchecked.
  -> expected [ 'validate-jsx-pages.ts', …(3) ] to deeply equal []

Test Files  1 failed (1) | Tests  2 failed | 4 passed (6)

After removing 16 COPY_LEDGER rows and 4 UNGUARDED_ALLOWANCE rows:
green, as part of the whole-package run below.

Why two ledger rows stay

validate-object-field-refs.ts and validate-list-view-field-refs.ts keep
their copies and their rows in both lists. The ledger's own note is the reason:
reference-integrity-suite.test.ts pins their finding paths
(objects[1].highlightFields[1]) against the author's file, and a reader that
drops a member renumbers past it. #15740 is open and pm:awaiting-maintainer
over 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 the
package — collectionEntries — and this PR uses it for four readers with the
same 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 evidence
option 2 is cheaper than it looked.

Verification, pinned to 173afb20e

command result
pnpm --filter @objectstack/lint test 99 files / 3375 passed, 5 skipped
pnpm --filter @objectstack/lint typecheck exit 0 (tsc --noEmit + check:test-typecheck: OK)
pnpm --filter '@objectstack/lint^...' build exit 0 (dependency closure, before any reading)
pnpm lint (eslint . --no-inline-config, repo-wide) exit 0
derived gates — node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack 54 derived; 52 green, 2 NOT MEASURED
--ran reconciliation 54 derived famil(ies) accounted for — 54 run, 0 NOT-MEASURED, 0 UNRUN

The two NOT MEASURED are PREREQUISITE NOT MET, exit 3 — the gates' own
"nothing was measured" code, not a finding: pnpm check:dual-build-cjs-loads
and pnpm check:type-check-debt both want a whole-repo dist/, which CI
builds. pnpm check:docs-transcript-drift was in that state too and is now
green — it reads @objectstack/lint's built registry, so it was worth building
this package to get a real verdict on it:

check-docs-transcript-drift: 4 declared transcript value(s) across 403 page(s)
under content/docs/ equal what the registry derives today

Gate exit codes were captured before any pipe.

Declared, not run: the branch base is 6a1e38244 and origin/main has moved
since; CI judges the merge ref. The gate derivation warned that two of the
family scripts it read have changed on main in that window
(check-changeset-no-major.mjs, check-type-source-resolution.mjs) — both
green here, both re-run by CI from their current copies.

Scope

@objectstack/lint is published, so .changeset/lint-collection-copies-guarded-readers.md (patch, bug class).
No content/docs/releases/ edit. object-graph.ts is unchanged. PR #15791
(card #15742) holds validate-expressions.ts and 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

…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
`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
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

24 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 01c80739320db8f56c355c57750078f69a40f736 — the merge of head 173afb20e95e68bad79f20b5b45433912624da3a into base d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 6, 2026 12:35
@baozhoutao
baozhoutao enabled auto-merge September 6, 2026 12:35
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 7a01847 Sep 6, 2026
35 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-15728-lint-recordsof-copies branch September 6, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants