Skip to content

fix(lint): no authoring rule throws on a non-record entry of any stack collection - #15751

Merged
baozhoutao merged 9 commits into
mainfrom
claude/issue-15636-non-record-readers-every-collection
Sep 5, 2026
Merged

fix(lint): no authoring rule throws on a non-record entry of any stack collection#15751
baozhoutao merged 9 commits into
mainfrom
claude/issue-15636-non-record-readers-every-collection

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15636

23 collection readers in packages/lint/src coerced a stack collection with a hand-copied asArray whose array branch was an unchecked cast, so a non-record member reached a dereference and threw a stack trace out of a rule that is contractually (stack) => Finding[]. 20 of them now read through the shared guarded recordsOf; a 21st (validate-chart-bindings.ts) landed as #15741 and this branch merges it; the last two are deferred with dated, self-expiring allowances, and a new source-text test pins that the predicate has one home.

Measured, before and after

Every AUTHORING_RULES entry (42) driven over five non-record shapes per collection, both legs in one worktree: AFTER at head, BEFORE by checking the 20 readers back to 1c1421401 and re-running (the tests import SOURCE, so no rebuild sits between the legs), then restoring — proven by git hash-object equal to the HEAD blob for all 20 and an empty git diff HEAD.

collection rules throwing BEFORE AFTER who
stack.flows 3 0 lintFlowPatterns, validateFlowTriggerReadiness, validateReferenceIntegrity
stack.permissions 2 0 validateCapabilityReferences, validateRlsPredicateEnforceability
objects[].fields 2 1 validateCapabilityReferences fixed; validateStackExpressions remains (#15742)
stack.pages 1 0 validateResponsiveStyles
stack.capabilities 1 0 validateCapabilityReferences
stack.actions 1 0 validateCapabilityReferences
stack.hooks 1 0 validateReferenceIntegrity
stack.data 1 0 validateCapabilityReferences
objects[].actions 1 0 validateCapabilityReferences
stack.datasets 1 1 → 0 validateReferenceIntegrity via validate-chart-bindings.ts; 1 after THIS change alone, 0 on the branch head since #15741 landed and was merged in
the other 18 swept collections 0 0 already guarded downstream

Distinct rules that throw on some collection: 7 before, 2 after for this change measured alone. #15741 then closed stack.datasets, so the branch head stands at 1validateStackExpressions, filed as #15742. The card said the per-collection count was unknown rather than zero; this is that measurement.

Phantom findings are measured the same way and are unchanged at 0 everywhere except stack.agents with an [] member, which draws 1 both before and after: the agent readers filter with !!x && typeof x === 'object', and an array passes that. recordsOf uses isRec, so #15728 closes it.

What changed

20 readers re-pointed (asArray deleted, call sites onto recordsOf): build-access-matrix, lint-flow-patterns, lint-liveness-properties, validate-action-locations, validate-action-name-refs, validate-approval-approvers, validate-capability-references, validate-component-props, validate-component-types, validate-dashboard-action-refs, validate-dataset-references, validate-flow-template-paths, validate-flow-trigger-readiness, validate-nav-access, validate-readonly-flow-writes, validate-readonly-hook-writes, validate-responsive-styles, validate-rls-predicate-enforceability, validate-semantic-roles, validate-translation-references.

Three of the 23 were not re-pointed here, each with a dated allowance that expires by failing. One of the three has since landed:

The one-copy gatepackages/lint/src/collection-coercion-single-copy.test.ts, a vitest source-text pin inside the package rather than a root check:* script. The family precedent for pinning facts about packages/lint/src's own source is in-package and vitest-shaped (rule-id-barrel-exports.test.ts, system-fields-consumers.test.ts and ten more read their own src/); the root gates that read packages/lint all scan many packages at once. So no root package.json row and no lint.yml step, which also keeps this diff off two files other seats are editing.

Three clauses, all exact in both directions:

  1. object-graph.ts declares exactly one coercion and it is recordsOf.
  2. Every other module declaring a (v: unknown) => AnyRec[] is in COPY_LEDGER — a new copy fails as unlisted, a re-pointed one fails as a stale row. Matching by SHAPE, not by the name asArray, so a renamed copy is still caught. 18 rows on the branch head: the two deferred readers plus the 16 of finding: sixteen lint collection readers still hold a private copy of the recordsOf coercion #15728.
  3. No coercion body carries the unchecked array branch outside UNGUARDED_ALLOWANCE (6 rows on the branch head), and an allowance whose file no longer casts unchecked also fails.

The family sweep, non-record-object-entry.test.ts, parameterised over 28 collections — the top-level keys every re-pointed reader reads, the TYPE_COLLECTIONS table lintLivenessProperties drives its dynamic read from, positions, and five per-object sub-collections — times five non-record shapes, times both assertions. The two residual throwers and the one residual phantom are written down as RESIDUAL_THROWS / RESIDUAL_INVENTED rows naming their cards, so the sweep reds both when a new one appears and when a filed one is fixed.

One existing pin moved: lint-liveness-properties.test.ts reported a bundle at translation bundle #2 and now reports #1, because that walk renumbers past the two unreadable members. The finding, the rule and the locale are identical; only the positional label moves, which is the trade #15552 settled explicitly ("the index is allowed to differ … a difference in the path, never in whether the object was judged"). Where a suite pins the author-file position instead, it keeps its raw walk — see the two reverted readers above.

Verification

Head 4281c5d60, which merges #15741. All numbers below are from that commit; shared-box seconds, several agents on this container. Exit codes captured by redirect-then-capture, never after a pipe.

  • pnpm --filter @objectstack/lint testTest Files 97 passed (97), Tests 3327 passed (3327), exit 0.
  • pnpm --filter @objectstack/lint typecheck — exit 0, check:test-typecheck: OK — @objectstack/lint's test layer compiles under packages/lint/tsconfig.test.json.
  • pnpm check:pm-dispatch-gatesdispatch-gates self-test: 1478 cases pass, exit 0.
  • pnpm check:nul-bytes — exit 0, check-nul-bytes: OK (scanned 7613 text file(s) … no raw ASCII control bytes).

The ordering evidence — both self-expiring lists fired

#15741 deletes validate-chart-bindings.ts's asArray. Both of this branch's exact-in-both-directions lists named that file, so merging it made them stale, and both went red saying so by name. That is the mechanism, not an accident, and it is why the two changes could not land in either order without one of them being corrected — which is what this branch did second.

Run on the merged tree with the pre-fix gate restored from HEAD (trap-guarded, mutation and restore both proven on disk, blob hash equal to the HEAD blob):

  • gate, rows still present: exit 1, Tests 2 failed | 4 passed (6)names no copy the ledger has outlived with "validate-chart-bindings.ts no longer declares a private coercion. Delete its COPY_LEDGER row", and allows no unchecked branch the allowance has outlived with "validate-chart-bindings.ts no longer casts unchecked. Delete its UNGUARDED_ALLOWANCE row".
  • gate, rows removed: exit 0, Tests 6 passed (6).
  • the family sweep hit the same thing one layer down: RESIDUAL_THROWS recorded validateReferenceIntegrity still throwing on stack.datasets, and after the merge that throw no longer happens, so two cases failed with expected [] to deeply equal [ 'validateReferenceIntegrity' ]. Those rows are deleted too; objects[].fields / validateStackExpressions (validateStackExpressions throws on a non-record entry of an object's fields: list — an inline cast the asArray sweeps could not see #15742) stays, still measured as throwing on this tree.

Measurements carried forward from the pre-merge head a0e0b3698

Re-stated rather than re-run, because nothing in this ordering fix touches a reader — the two commits since only delete stale rows from two lists.

  • Census before/after (42 AUTHORING_RULES over five non-record shapes across 28 collections), the table above: AFTER at head, BEFORE by checking the 20 readers back to 1c1421401 in the same worktree. The tests import SOURCE via vitest, never dist/, so no rebuild sits between the legs; restore proven by git hash-object equal to the HEAD blob for all 20 and an empty git diff HEAD.
  • Test-count growth over the true baseline (3028 tests / 96 files at 1c1421401): +286 / +1 file, and 286 = 280 (28 collections x 5 shapes x 2 assertions) + 6 (the gate). The head now reads 3327 because the merges brought main's own new tests.
  • Ablation of the gate, trap-guarded, absolute paths, both legs. Leg 1: re-introduce the unchecked copy in validate-semantic-roles.ts — 2 clauses red, both naming the file. Leg 2: add a ledger row for filter-walk.ts, which declares no coercion — names no copy the ledger has outlived reds naming it. Each mutation proven on disk by counting the injected marker and the blob hash differing from HEAD; each restore proven by git hash-object equal to the HEAD blob, an empty git diff HEAD, marker count back to 0, and the gate green again. No build sits inside this ablation: the gate imports only node:fs/node:path/vitest and reads src/ off disk, so it tests SOURCE and never dist/.
  • Derived family on the pre-merge head: node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack — 53 commands, all run, 51 exit 0, plus the 4 rosters the tool marks as sitting under a directory in this diff, check:changeset-fixed, check:partof-closing-keyword, check:stack-collection-maps, check:self-test-wired — all exit 0. The change set is the same 24 paths after the merge.
  • NOT MEASURED (exit 3, PREREQUISITE NOT MET, both read a whole-workspace build this worktree does not have): check:dual-build-cjs-loads, check:type-check-debt. Neither is a red. Also NOT MEASURED: the 7 families whose argv takes a value only CI supplies, and the repo-wide pnpm lint, left to CI.

Findings filed (none fixed here)

Open question 1 above is #15740, carried to the maintainer by the dispatching seat with recommendation B (an index-preserving companion in object-graph.ts); it is deliberately not implemented here. Open question 2 is settled: the two-clause gate as shipped is the contract, and #15728 empties the ledger.

Not touched: object-graph.ts, validate-chart-bindings.ts (its re-point is #15741's, merged in), validate-widget-bindings.ts (which carries no such copy), root package.json, lint.yml.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 29 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via validateRlsPredicateEnforceability (symbol, a top-level function))
  • content/docs/permissions/authorization.mdx (via buildAccessMatrix (symbol, a top-level function), validateCapabilityReferences (symbol, a top-level function))
  • content/docs/permissions/capabilities.mdx (via validateCapabilityReferences (symbol, a top-level function))
  • content/docs/permissions/permission-sets.mdx (via validateCapabilityReferences (symbol, a top-level function))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via buildAccessMatrix (symbol, a top-level function), validateCapabilityReferences (symbol, a top-level function))
  • content/docs/releases/v16.mdx (via validateDashboardActionRefs (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 66e68adc667acaca9035c135f57d9f13c8cde56apackageMentionDocs.

Which tree this was computed on

This run read content/docs from df86a1e311a21cb59b2591f5cc7fb4bc54aa029b — the merge of head 4281c5d609e888771b268c1e551e2ffadb18b8bb into base 66e68adc667acaca9035c135f57d9f13c8cde56a, 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 df86a1e311a21cb59b2591f5cc7fb4bc54aa029b && git checkout df86a1e311a21cb59b2591f5cc7fb4bc54aa029b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 66e68adc667acaca9035c135f57d9f13c8cde56a 4281c5d609e888771b268c1e551e2ffadb18b8bb && git checkout -B drift-repro 66e68adc667acaca9035c135f57d9f13c8cde56a && git merge --no-ff 4281c5d609e888771b268c1e551e2ffadb18b8bb

node scripts/docs-audit/affected-docs.mjs --json 66e68adc667acaca9035c135f57d9f13c8cde56a

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 66e68adc667acaca9035c135f57d9f13c8cde56a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

#15741 re-pointed `validate-chart-bindings.ts` onto `recordsOf`, so both rows
naming it here — the `COPY_LEDGER` entry and the `UNGUARDED_ALLOWANCE` entry —
stopped describing the package. Both lists are exact in both directions, so the
gate went red on `names no copy the ledger has outlived` and `allows no
unchecked branch the allowance has outlived`, each naming the file. That is the
mechanism working: the allowance was dated to the change that removes it, and it
came out because the test failed rather than because anyone remembered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Same self-expiry, one layer down from the gate. `RESIDUAL_THROWS` recorded that
`validateReferenceIntegrity` still threw on a non-record `stack.datasets` member
through `indexDatasets`; #15741 re-pointed that reader, so the sweep went red
demanding a throw that no longer happens. The rows come out because the
assertion failed, which is what "exact in both directions" is for — a residual
list that only ever grew would rot into a list of excuses.

`objects[].fields` / `validateStackExpressions` (#15742) stays: measured, still
throwing on this tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #15751 (head 4281c5d60, 24 files, +549/−274) reviewed against the GitHub three-dot diff on the merged tree, not the report.

The ordering fix is exactly what was asked, and the mechanism proved itself: with #15741 merged in (ba89aa79c merge commit; a51eb86b7 an ancestor), the two validate-chart-bindings.ts rows are gone from COPY_LEDGER / UNGUARDED_ALLOWANCE (the only remaining mention is the docblock), and the sweep's two stack.datasets RESIDUAL_THROWS rows — stale for the same reason — are gone too, while the objects[].fields / validateStackExpressions row (#15742) stays, still measured as throwing. The pre-fix gate restored on the merged tree reds by name (Tests 2 failed | 4 passed (6)), the fixed one is green (6 passed). Head-to-head from the reviewed a0e0b3698, the only change under packages/lint/src beyond the merge is the 10-line row removal in non-record-object-entry.test.ts; the 20 re-pointed readers, the reverted pair, the changeset and the gate's structure are unchanged.

Measured by this seat: git merge-tree --write-tree origin/main <branch> → EXIT=0; no live PR of this lane touches packages/lint/**; both ledgers grep clean on the head. NOT MEASURED here: the lint suite and typecheck (no deps in this checkout; the report's locked run Tests 3327 passed (3327) / check:test-typecheck: OK and dispatch-gates self-test: 1478 cases pass are the record).

Rulings stand: Q2 A; Q1 → #15740 (decision box). Flip + arm when both required jobs report success on 4281c5d60. On landing #15636 closes and #15742 becomes dispatchable (serial on the sweep file).


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review September 5, 2026 06:50
@baozhoutao
baozhoutao enabled auto-merge September 5, 2026 06:50
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Flipped ready + auto-merge enabled (06:50Z, method: MERGE). Required jobs on 4281c5d60: Lint & Repo Gates = success, TypeScript Type Check = success; git merge-tree --write-tree origin/main <branch> → EXIT=0 against the current main. Watched; on landing #15636 closes and #15742 becomes dispatchable.


Generated by Claude Code

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Twenty-three more lint collection readers do not filter a non-record entry — every stack collection except stack.objects

2 participants