fix(lint): every stack.objects reader skips a non-record entry, so no authoring rule throws on the publish door - #15635
Conversation
… 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
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
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 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
|
|
ACCEPT — PM seat Reviewed against the three-dot diff What I checked in the diff:
Dev's verification quoted (on Out-of-scope finding filed by the dev: #15636 (23 more files carry the same undefended Flipping ready + enabling auto-merge. Fixes #15552. Generated by Claude Code |
Fixes #15552
A
nullmember ofstack.objectscrashed 13 of the 42AUTHORING_RULES. The rules are pure(stack) => Finding[](ADR-0019) and run on the RAWlintpath 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, andos lint/os validate/os compiledied 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 ofstack.objectsin@objectstack/lintnow 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_RULEStable with a throwaway harness (deleted before the commit; the tree is clean). Every shape is{ objects: [JUNK, validObject] }; the control is{ objects: [validObject] }.6b8c67778)e7cede2ca)nullundefined{}(a record — deliberately KEPT)'x'42[][validObject]Only
null/undefinedever threw:null.namethrows where({}).nameand'x'.nameare merelyundefined. The 13 rows reproduced the card's table exactly, with one line number moved by #15571 (validate-widget-bindings.ts:465is now:621):validateStackExpressionsvalidate-expressions.ts:141validateListViewModevalidate-list-view-mode.ts:149validateWidgetBindingsvalidate-widget-bindings.ts:621validateFilterTokensfilter-walk.ts:155validatePresetComparandsfilter-walk.ts:155validateEmptyCombinatorsfilter-walk.ts:155validateReferenceIntegrityvalidate-object-references.ts:142validateRecordTitlevalidate-record-title.ts:75validateFormLayoutvalidate-form-layout.ts:126lintAutonumberFormatslint-autonumber-formats.ts:56lintViewRefslint-view-refs.ts:204validateOrgAxisRedLinesvalidate-org-axis-red-lines.ts:189validateSharingRuleEnforceabilityvalidate-sharing-rule-enforceability.ts:432The 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.
recordsOfisobject-graph.ts's formerly privateasArray, exported (package-private; not added to the barrel) with its docblock extended; the fifteen hand-copied undefendedasArraybodies were deleted and their call sites re-pointed onto it, matching how #14575 consolidatedsuggestName.object-graph.ts—recordsOf(the export)validate-expressions.ts—buildFieldIndexvalidateStackExpressionsvalidate-list-view-mode.tsvalidateListViewModevalidate-widget-bindings.ts— the aggregate-coherence pass ahead of the graph seamvalidateWidgetBindingsfilter-walk.ts—walkAuthoredFiltersvalidateFilterTokens,validatePresetComparands,validateEmptyCombinatorsvalidate-object-references.tsvalidateReferenceIntegrity(suite member that threw first)validate-record-title.tsvalidateRecordTitlevalidate-form-layout.tsvalidateFormLayoutlint-autonumber-formats.tslintAutonumberFormatslint-view-refs.tslintViewRefsvalidate-org-axis-red-lines.tsvalidateOrgAxisRedLinesvalidate-sharing-rule-enforceability.tsvalidateSharingRuleEnforceabilityvalidate-searchable-fields.ts—indexObjectSearchTargetsvalidateSearchableFields,validateSortableFields(suite)validate-page-field-bindings.ts—indexObjectFieldsvalidatePageFieldBindings,validateReactPageProps(suite)object-field-groups.ts—indexObjectFieldGroupsvalidateReferenceIntegrity,validateFormLayout— added 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.tsvalidateSecurityPosture— added by the re-measure, and the one that never threw (see below)The seam that reported instead of crashing
validate-security-posture.tscarried the identical undefended copy, and an[]member passed itstypeof v === 'object'read intact: it drew a SECONDsecurity-owd-unsetatobject "(object 0)"— anerror-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: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 forIDENT.MEMBERpairs, so the newimport { recordsOf } from './object-graph.js';presented as a receivergraphreading a keyjs. 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 underPLUMBINGas 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
objectsmember 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 omitssharingModelsosecurity-owd-unsetfires). Only a path index moves, and only for rules that indexobjectsraw:validateSecurityPosturereportsobjects[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:AUTHORING_RULES.length >= 42), so a shrunken or unloaded table cannot make the sweep vacuous.{}case pinning that a record entry is NOT dropped.Reverse verification (ablation) —
recordsOf's array branch reverted tov as AnyRec[], restore driven by an EXIT/INT/TERM trap on an absolute path: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 nodist/sits between the mutation and the assertion.Verification — all at
e7cede2ca, exit codes captured before any pipepnpm --filter '@objectstack/lint^...' buildos-verify-lock: VERDICT command-exit 0pnpm --filter @objectstack/lint testTest Files 96 passed (96)·Tests 3001 passed | 5 skipped (3006)pnpm --filter @objectstack/lint typecheckcheck:test-typecheck: OK — @objectstack/lint's test layer compilespnpm lint(whole repo,eslint . --no-inline-config)node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstackcheck:stack-collection-mapspnpm check:pm-dispatch-gatesdispatch-gates self-test: 1445 cases pass.pnpm check:stack-collection-maps8 enumerations reconciled against 31 declared collectionspnpm check:nul-bytesOK (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-loadsandpnpm check:type-check-debt's--re-measurehalf. Neither is a red; both self-tests passed, andcheck:type-check-coverageitself 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 --listFilesnamesnon-record-object-entry.test.ts, and the only errors it reports are the 6 pre-existing ledgeredTS6059s in two other files.Changeset:
@objectstack/lintpatch — 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