Skip to content

fix(lint): every stack.objects reader skips a non-record entry, so no authoring rule throws on the publish door - #15635

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-15552-non-record-objects-readers
Sep 5, 2026
Merged

fix(lint): every stack.objects reader skips a non-record entry, so no authoring rule throws on the publish door#15635
baozhoutao merged 1 commit into
mainfrom
claude/issue-15552-non-record-objects-readers

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15552

A null member of stack.objects crashed 13 of the 42 AUTHORING_RULES. The rules are pure (stack) => Finding[] (ADR-0019) and run on the RAW lint path as well as the parsed one, so nothing upstream had judged the entry's shape; at the runtime publish gate they are called inside the gate rather than behind a try/catch of their own, so the throw was an exception on a WRITE path rather than a skipped finding, and os lint / os validate / os compile died on the first one instead of reporting the stack. An empty YAML list item deserialises to exactly that.

#15562 guarded ONE seam (indexObjectGraph). This repair takes the class: every reader of stack.objects in @objectstack/lint now goes through the guarded reader, so a member the reader cannot read is DROPPED, never reported.

Re-measured first, on this branch's base

Driven over the whole AUTHORING_RULES table with a throwaway harness (deleted before the commit; the tree is clean). Every shape is { objects: [JUNK, validObject] }; the control is { objects: [validObject] }.

shape throws before (6b8c67778) throws after (e7cede2ca)
null 13 / 42 0 / 42
undefined 13 / 42 0 / 42
{} (a record — deliberately KEPT) 0 / 42 0 / 42
'x' 0 / 42 0 / 42
42 0 / 42 0 / 42
[] 0 / 42 0 / 42
control [validObject] 0 / 42 0 / 42

Only null / undefined ever threw: null.name throws where ({}).name and 'x'.name are merely undefined. The 13 rows reproduced the card's table exactly, with one line number moved by #15571 (validate-widget-bindings.ts :465 is now :621):

rule threw at message
validateStackExpressions validate-expressions.ts:141 Cannot read properties of null (reading 'name')
validateListViewMode validate-list-view-mode.ts:149 …(reading 'name')
validateWidgetBindings validate-widget-bindings.ts:621 …(reading 'name')
validateFilterTokens filter-walk.ts:155 …(reading 'name')
validatePresetComparands filter-walk.ts:155 …(reading 'name')
validateEmptyCombinators filter-walk.ts:155 …(reading 'name')
validateReferenceIntegrity validate-object-references.ts:142 …(reading 'name')
validateRecordTitle validate-record-title.ts:75 …(reading 'name')
validateFormLayout validate-form-layout.ts:126 …(reading 'name')
lintAutonumberFormats lint-autonumber-formats.ts:56 …(reading 'name')
lintViewRefs lint-view-refs.ts:204 …(reading 'name')
validateOrgAxisRedLines validate-org-axis-red-lines.ts:189 …(reading 'tenancy')
validateSharingRuleEnforceability validate-sharing-rule-enforceability.ts:432 …(reading 'name')

The seams, and the two the re-measure added

The fix is one guard per READER, not per rule — several rules share a reader, and inside the reference-integrity suite the first throwing member hides every member behind it. recordsOf is object-graph.ts's formerly private asArray, exported (package-private; not added to the barrel) with its docblock extended; the fifteen hand-copied undefended asArray bodies were deleted and their call sites re-pointed onto it, matching how #14575 consolidated suggestName.

seam rules behind it
object-graph.tsrecordsOf (the export) the field-path family, already guarded by #15562
validate-expressions.tsbuildFieldIndex validateStackExpressions
validate-list-view-mode.ts validateListViewMode
validate-widget-bindings.ts — the aggregate-coherence pass ahead of the graph seam validateWidgetBindings
filter-walk.tswalkAuthoredFilters validateFilterTokens, validatePresetComparands, validateEmptyCombinators
validate-object-references.ts validateReferenceIntegrity (suite member that threw first)
validate-record-title.ts validateRecordTitle
validate-form-layout.ts validateFormLayout
lint-autonumber-formats.ts lintAutonumberFormats
lint-view-refs.ts lintViewRefs
validate-org-axis-red-lines.ts validateOrgAxisRedLines
validate-sharing-rule-enforceability.ts validateSharingRuleEnforceability
validate-searchable-fields.tsindexObjectSearchTargets validateSearchableFields, validateSortableFields (suite)
validate-page-field-bindings.tsindexObjectFields validatePageFieldBindings, validateReactPageProps (suite)
object-field-groups.tsindexObjectFieldGroups validateReferenceIntegrity, validateFormLayoutadded by the re-measure: invisible until the eleven sites above stopped throwing, then it was the last crash standing (2 of 42)
validate-security-posture.ts validateSecurityPostureadded by the re-measure, and the one that never threw (see below)

The seam that reported instead of crashing

validate-security-posture.ts carried the identical undefended copy, and an [] member passed its typeof v === 'object' read intact: it drew a SECOND security-owd-unset at object "(object 0)" — an error-severity finding about an entry no author wrote, on the same publish door. Same defect class, same reader shape, same verdict as the card's ruling ("DROP the non-record entry, do not report it"), so it is fixed here rather than filed: without it the family sweep below could not run over the [] shape at all, and the phantom would stay live. Evidence, from the re-measure diff against the valid-only control:

DIFF junk=[] validateSecurityPosture
  base=[{… "where":"object \"crm_account\"", "path":"objects[0].sharingModel" …}]
  junk=[{… "where":"object \"(object 0)\"", "path":"objects[0].sharingModel" …},
        {… "where":"object \"crm_account\"", "path":"objects[1].sharingModel" …}]

It is the only rule whose output changes for any shape other than a path index. No rule tier, id, message or accept-set changes anywhere.

One collateral edit, and why it is not a silenced gate

validate-security-posture.test.ts's #5017 receiver-coverage meta-test scans the rule's source text for IDENT.MEMBER pairs, so the new import { recordsOf } from './object-graph.js'; presented as a receiver graph reading a key js. The scan now strips import statements before scanning, because an import SPECIFIER is a module path and never a read off a metadata record — the alternative was to file a module path under PLUMBING as though it were a local variable, which would be a lie the next author has to re-derive. The scan's power over real receivers is unchanged (109/109 of that file's tests pass, the exact-match table included).

Skip, not report — and the valid object is still judged

The verdict follows the seam this extends: a junk objects member is a SHAPE defect and belongs to the schema; every rule already re-answers the question in its own per-object guard; and reporting at the reader would emit one finding per member for one bad entry. On the name-keyed MAP shape a member whose value is unreadable keeps its key ({ name }) — the author named it, only its body is illegible.

A guard that skips could also skip everything, so the family test counts findings as well as throws: with junk beside it the table emits the SAME number of findings as for the valid object alone (2, non-zero by construction — the fixture omits sharingModel so security-owd-unset fires). Only a path index moves, and only for rules that index objects raw: validateSecurityPosture reports objects[1], which is where the author actually wrote it.

Tests

packages/lint/src/non-record-object-entry.test.ts (new; existing per-rule tests untouched) — 28 cases:

  • The table floor first (AUTHORING_RULES.length >= 42), so a shrunken or unloaded table cannot make the sweep vacuous.
  • Family sweep: five non-record shapes x (no rule throws + the valid object is still judged), plus the {} case pinning that a record entry is NOT dropped.
  • One case per reader seam, so a seam that forks its own copy again fails by name rather than only inside the sweep.

Reverse verification (ablation) — recordsOf's array branch reverted to v as AnyRec[], restore driven by an EXIT/INT/TERM trap on an absolute path:

HEAD blob:            10f1b138e31ece4cb3cd31a6c77d22d263d9fb7d
pre-mutation hash:    10f1b138e31ece4cb3cd31a6c77d22d263d9fb7d
guarded-line count before=1 after=0 ; injected-line count after=1
post-mutation hash:   720e995d06d9df9d01c2850756f9ede2ca7c2c6d
MUTATED_LEG_EXIT=1     Tests  23 failed | 5 passed (28)
restored hash:        10f1b138e31ece4cb3cd31a6c77d22d263d9fb7d   (== HEAD blob)
git diff HEAD:        (empty)
RESTORED_LEG_EXIT=0    Tests  28 passed (28)

No rebuild leg is claimed or needed: the test imports the mutated module by relative path inside its own package, so vitest transforms src/ and no dist/ sits between the mutation and the assertion.

Verification — all at e7cede2ca, exit codes captured before any pipe

command exit verdict line
pnpm --filter '@objectstack/lint^...' build 0 os-verify-lock: VERDICT command-exit 0
pnpm --filter @objectstack/lint test 0 Test Files 96 passed (96) · Tests 3001 passed | 5 skipped (3006)
pnpm --filter @objectstack/lint typecheck 0 check:test-typecheck: OK — @objectstack/lint's test layer compiles
pnpm lint (whole repo, eslint . --no-inline-config) 0 clean
node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack 0 52 commands derived from 19 paths
the 52 derived commands + check:stack-collection-maps 0 50 green; see NOT MEASURED below
pnpm check:pm-dispatch-gates 0 dispatch-gates self-test: 1445 cases pass.
pnpm check:stack-collection-maps 0 8 enumerations reconciled against 31 declared collections
pnpm check:nul-bytes 0 OK (scanned 7567 text file(s) … no raw ASCII control bytes)

NOT MEASURED (exit 3, PREREQUISITE NOT MET — both need the whole repo built, which CI does and this container did not): pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt's --re-measure half. Neither is a red; both self-tests passed, and check:type-check-coverage itself is green (75/79 workspace packages type-checked). The new test file is inside the tsc program that checks it — tsc -p tsconfig.test.json --listFiles names non-record-object-entry.test.ts, and the only errors it reports are the 6 pre-existing ledgered TS6059s in two other files.

Changeset: @objectstack/lint patch — a crash becomes a skip on the publish door and the CLI; no accept-set change.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

… authoring rule throws on the publish door (#15552)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@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 27 documentable anchor(s).

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

  • content/docs/deployment/validating-metadata.mdx (via validateSharingRuleEnforceability (symbol, a top-level function))
  • content/docs/permissions/authorization.mdx (via validateSecurityPosture (symbol, a top-level function))

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

  • content/docs/releases/v12.mdx (via validateFormLayout (symbol, a top-level function), validateListViewMode (symbol, a top-level function))
  • content/docs/releases/v13.mdx (via validateSecurityPosture (symbol, a top-level function))
  • content/docs/releases/v16.mdx (via validateStackExpressions (symbol, a top-level function), validateWidgetBindings (symbol, a top-level function))
  • content/docs/releases/v17.mdx (via validateFormLayout (symbol, a top-level function), validateSecurityPosture (symbol, a top-level function), validateWidgetBindings (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 d30ccb9bd963942d33072443f680a32664087346packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json d30ccb9bd963942d33072443f680a32664087346

⚠️ 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 d30ccb9bd963942d33072443f680a32664087346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff origin/main...claude/issue-15552-non-record-objects-readers (head e7cede2ca; 19 files, +426/−231), not the self-report. git merge-tree against the live #15495 branch (reference-integrity-suite.ts, runtime-gate.object-writes.test.ts, its changeset, one metadata-protocol test): 0 conflicts; neither hot file touched.

What I checked in the diff:

  • object-graph.ts: the private asArray becomes the package's ONE collection reader, export function recordsOf (not barrelled), with the skip-not-report docblock extended by a "Why it is exported" section; the array branch filters non-records, the map branch keeps a keyed member with an unreadable body as { name } (the key is the author's own declaration) — both stated and pinned.
  • Thirteen hand-copied asArray bodies deleted and re-pointed (filter-walk, validate-expressions, validate-list-view-mode, validate-object-references, validate-record-title, validate-form-layout, validate-widget-bindings, validate-searchable-fields, lint-view-refs, lint-autonumber-formats, validate-org-axis-red-lines, validate-page-field-bindings, validate-sharing-rule-enforceability, object-field-groups) — per READER seam as ruled, the fix(lint): consolidate did-you-mean suggestion helpers into object-graph's shared nearestName #14575 suggestName consolidation shape; no rule tier, id, message or accept-set moved.
  • Two seams beyond the five named, found by the re-measure: object-field-groups.ts (indexObjectFieldGroups, the last crash standing) and validate-security-posture.ts (never threw, but let [] through a typeof object read and drew a phantom security-owd-unset at (object 0)) — same class, same verdict, fixed in place with evidence. One collateral edit: validate-security-posture.test.ts's validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017 receiver-coverage scan strips import statements so ./object-graph.js is not read as a receiver — the alternative (filing a module path under PLUMBING) would have been worse.
  • non-record-object-entry.test.ts (28 cases): table floor AUTHORING_RULES.length ≥ 42, the family sweep over five non-record shapes × (no throw + the valid sibling still judged), a {} case pinning that a RECORD entry is not dropped, one case per seam. Changeset @objectstack/lint patch.

Dev's verification quoted (on e7cede2ca): measurement before/after — null 13 → 0, undefined 13 → 0, the other shapes 0 → 0, population pin 2 == 2 for every shape (the only output difference is validateSecurityPosture's path index, which indexes objects raw by design); pnpm --filter @objectstack/lint test Test Files 96 passed (96), Tests 3001 passed | 5 skipped; typecheck check:test-typecheck: OK with both new/edited test files listed by tsc --listFiles; whole-repo lint clean; trap-guarded ablation of recordsOf's array guard → 23 failed | 5 passed, restore by blob 10f1b138e + empty git diff HEAD; derived family 52 commands + 2 run voluntarily, 50 green, 2 NOT MEASURED by their own PREREQUISITE NOT MET (whole-repo build); one lock timeout recorded and re-run.

Out-of-scope finding filed by the dev: #15636 (23 more files carry the same undefended asArray for every OTHER stack collection — flows, pages, dashboards, …; a source-text gate pinning ONE coercion in the package is the suggested shape). ⚠️ Worth grading p2 by the same argument as this card.

Flipping ready + enabling auto-merge. Fixes #15552. validate-widget-bindings.ts is touched here → #15508 waits for this to land.


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.

Twelve more authoring rules crash on a non-record entry in stack.objects — five unguarded readers beyond the indexObjectGraph seam

2 participants