fix(lint,metadata-protocol): a junk entry in stack.objects no longer crashes the reference-integrity seam, and a throwing probe rule is reported (#15494) - #15562
Conversation
…crashes the reference-integrity seam, and a throwing probe rule is reported
`indexObjectGraph` is the first statement of every rule that resolves a field
path, and its local `asArray` returned an array unchanged — so a `null` member
of `stack.objects` reached `strName(obj.name)` and threw
`TypeError: Cannot read properties of null (reading 'name')` before any
member's own `if (!isRec(obj)) continue` could run. These rules are pure
`(stack) => Finding[]` and run on the raw `lint` path as well as the parsed
one, and at the runtime publish gate they are called inside the gate: a throw
there is an exception on a write path, not a skipped finding.
The entry is SKIPPED, not reported. Every sibling `asArray` in this package
that spells the defensive read drops the member silently, each member of the
family already answers the same question three lines below the call, and this
module decides no severities by contract. Driving the whole `AUTHORING_RULES`
table over `{ objects: [null, validObject] }` measured 28 rules judging it in
silence and none reporting the junk entry.
Second half, on the receipt: `runBuildProbes`' object plane wrapped the rule
in `catch { findings = [] }`, so a crash produced the byte-identical receipt a
clean object produces while `checked.objects` had already counted it. It now
emits a `runtime`-layer `object_field_ref_rule_failed` error carrying the
thrown message. Probes still never fail the publish they verify.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…rotocol for the object-graph null-entry guard Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…ject-graph-null-entry
…D6c on the literal's own line check:error-code-casing reads the closed SCREAMING_SNAKE catalog; a build-probe diagnostics code shipped inside a 200 receipt is D6c, which is why the gate exempts build-probes.ts and the objectql probe test whole. The per-literal mark is the narrower spelling of the same exemption. Written on the literal's own line deliberately: a multi-line comment above it was measured to move the literal out of the gate's recognition window, so the gate went green with the mark deleted — a suppression that was really a blind spot. Both marks are now load-bearing (removing either reds the gate). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…te pin does NOT cover Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
📓 Docs Drift Check11 anchor(s) derived from 2 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 — 14 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 370ff4a827a5a3d172fb9f6706b544ae05d53401 && git checkout 370ff4a827a5a3d172fb9f6706b544ae05d53401
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 ba6dd6e806e5c6ef47e8f7dea87fd5a45efb704c && git checkout -B drift-repro cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 && git merge --no-ff ba6dd6e806e5c6ef47e8f7dea87fd5a45efb704c
node scripts/docs-audit/affected-docs.mjs --json cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 |
|
ACCEPT + ruling — PM seat Reviewed against the three-dot diff Ruling on the open question: A — keep Flipping ready and arming auto-merge now. Generated by Claude Code |
|
Standing-down note — PM seat This PR has been ARMED but red since 21:30:35Z: Spending the ONE re-run the dispatch rules allow: Generated by Claude Code |
Fixes #15494
indexObjectGraphis the first statement of every rule that resolves a field path, and its localasArrayreturned an array unchanged — so anullmember ofstack.objectsreachedstrName(obj.name)and threw before any rule's own per-object guard could run. One guard at the shared seam, and the whole family inherits it.The decision this card asked for: SKIPPED, not reported
A non-record entry is silently dropped. Three independent readings pointed the same way, and the third is a measurement rather than an argument:
asArraycopy in@objectstack/lintthat spells the defensive read at all drops the member and says nothing —validate-nav-target-refs.ts,validate-flow-node-writes.ts,validate-hook-body-writes.ts,validate-page-visualization-bindings.ts,validate-readonly-action-writes.ts,validate-rule-compilability.ts,validate-view-page-refs.tsand the rest. Not one emits a finding. The seam was the outlier, not the reporters.if (!isRec(obj)) continuein its own per-object loop. A report from the seam would contradict the guard the same rule is about to run.lintpath, measured. Driving the wholeAUTHORING_RULEStable (42 rules) over{ objects: [null, validObject] }: 28 rules judge it in silence, 13 throw, and 0 report the junk entry. The one rule that reports anything isvalidateSecurityPosturewithsecurity-owd-unset, and itspathmoves fromobjects[1].sharingModeltoobjects[0].sharingModelwhen the junk entry is removed — a finding about the VALID object, not about the junk.How the parsed path can produce one at all: it mostly cannot, and that is the point — these rules are pure
(stack)toFinding[](ADR-0019) and run on the raw path too, whereobjectsis whatever the author's files deserialised to. An empty YAML list item is exactlynull. A shape defect is the schema's subject, not reference integrity's, andobject-graph.tsdecides no severities and holds no rule ids by contract — reporting from there would also emit the same finding once per member for one bad entry.Reproduction
On
origin/mainat615fac3a0, before the change:After, with the valid object beside it still judged:
The reported positions are unchanged —
objects[1].highlightFields[1],objects[1].listViews.all.columns[1],datasets[0].dimensions[0].field. The guard drops the entry from the GRAPH while each member's own loop keeps walking the raw array, so paths still count the junk entry and stay stable against the author's file.What changed
packages/lint/src/object-graph.ts—asArrayfilters non-records in both of its uses:stack.objects, andobj.fieldsone level down (fields: [null]crashed at the identical statement). The name-keyed map branch stops spreading a non-record value; the verdict for{ a: 'junk' }isno-field-mapbefore and after.packages/metadata-protocol/src/build-probes.ts— the object plane'scatch { findings = [] }becomes aruntime-layerobject_field_ref_rule_failederror (severityerror) carrying the thrown message. A rule that throws never again reads as zero findings on a receipt whosechecked.objectsalready counted the object. Probes still never fail the publish they verify.object-graph.test.ts; one case perindexObjectGraphcaller in the sharedreference-integrity-suite.test.tsharness;build-probes-rule-failure.test.tsfor the probe half. Changeset:@objectstack/lintpatch +@objectstack/metadata-protocolpatch.Reverse verification
Each half was ablated from the committed state, with the mutation proved on disk (marker counts + blob equality against
615fac3a0) and the restore proved by blob hash and an emptygit diff HEAD. Expected direction was red-on-revert for each half's own tests and no others; that is what happened.object-graph.tsguard reverted to the base form1to0; base form present; mutated blob35b0a44aequals base blob35b0a44aobject-graph.test.ts: 2 failed | 23 passed —AssertionError: expected [Function] to not throw an error but 'TypeError: Cannot read properties of …' was thrownbuild-probes.tscatch reverted to the base form3to0; base form present; mutated blob015f56c2equals base blob015f56c2build-probes-rule-failure.test.ts: 2 failed | 2 passed —AssertionError: expected [] to have a length of 1 but got +0The second line is the defect in one sentence: an empty list where a crash happened.
One gate found a real problem, and then a phantom fix for it
check:error-code-casingred-flagged the new test file'sobject_field_ref_rule_failedliterals (ADR-0112 D1 wants SCREAMING_SNAKE). The code is D6c — build-probe diagnostics shipped inside a 200 receipt, the same vocabulary the gate already exemptsbuild-probes.tsandpackages/objectql/src/build-probes.test.tswhole for — so the per-literaladr0112-ok:mark is the narrower spelling of that exemption, and no gate script or ledger was edited.Worth recording: the marks were first written as a comment block above the literal, and that read as green while suppressing nothing — the extra lines moved the literal out of the gate's recognition window entirely, so deleting the mark left the gate green too. Falsifying each mark individually caught it. They are now on the literal's own line (the spelling the gate's own self-test pins), and removing either one reds the gate.
What this does NOT fix — filed as #15552, not ridden here
The card's premise holds for the seam it names, but it is narrower than the class. Measured after this change, 13 of 42
AUTHORING_RULESentries still throw on{ objects: [null] }, through five more unguarded readers ofstack.objects:packages/lint/src/validate-widget-bindings.ts:465— the fourthindexObjectGraphcaller, which dereferences the entry in its aggregate-coherence pass BEFORE it reaches the seam, so this guard cannot reach it. That file is held by PR feat(lint): report a chart widget that selects no measures — or no dimensions (#15462) #15507, which is open and armed.validate-object-references.ts:142,indexObjectSearchTargets(validate-searchable-fields.ts:287, two rules),indexObjectFields(validate-page-field-bindings.ts:358, two rules) — sovalidateReferenceIntegrity({ objects: [null] })still throws.filter-walk.ts:155and six other single-rule readers.⭐ That is stated in the suite test's own docblock as well, so the pin cannot be misread as covering the family entry point. The full inventory with per-rule throw sites and a control leg is in #15552, which is unassigned and is not addressed by this PR.
Verification
Exit codes captured before any pipe; verdict lines quoted as the tools printed them. Final commit for the gate re-run named below.
pnpm --filter @objectstack/lint testTest Files 95 passed (95),Tests 2946 passed (2946),VERDICT command-exit 0pnpm --filter @objectstack/lint typecheckcheck:test-typecheck: OK — @objectstack/lint's test layer compiles … 2 file(s) / 6 error(s) / 2 pinned signature(s) held(unchanged ledger)@objectstack/metadata-protocol— 10 probe / publish-drafts suitesTest Files 10 passed (10),Tests 57 passed (57),VERDICT command-exit 0pnpm --filter @objectstack/metadata-protocol typechecktsc --noEmit, clean)packages/objectqlsrc/build-probes.test.ts(theprobes.checkedpin)Test Files 1 passed (1),Tests 10 passed (10)— run afterpnpm --filter @objectstack/lint --filter @objectstack/metadata-protocol build, so it read the newdist, not a stale onenode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackpnpm -s check:pm-dispatch-gates✓ dispatch-gates self-test: 1402 cases pass.pnpm check:nul-bytes✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path)pnpm check:engine-double-contractcheck-engine-double-contract: OK — 781 pinned, 133 in the DEBT ledger, 3 exempt.(no--writeneeded)pnpm check:objectql-double-limitOK ObjectQL doublelimitconformance holds: 325 double(s) graded, 126 apply the caller's bound or refuse it loudly.pnpm check:error-code-casing✓ no unlisted lowercase error codes in 5430 scanned file(s) (ADR-0112).(was exit 1; see above)pnpm check:cross-package-test-inputsOK: 26 package(s) read outside themselves, all declaredpnpm check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scannedpnpm lint(eslint . --no-inline-config)The gate family was derived after merging
origin/main— the first derivation carried a⚠️ STALE TREEbanner naming 3 files the families derive from as changed, so it was discarded rather than reported. The re-derivation on the merged tree reports--repo 'objectstack-ai/objectstack' checked against this checkout's 'origin' remote — it holds.and yields the identical 48-command family.The two exit-3s are NOT MEASURED, and are not read as green:
pnpm check:dual-build-cjs-loads—Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured.pnpm check:type-check-debt—⛔ This is NOT a pass and NOT a finding: nothing was measuredBoth need a full monorepo build closure, which CI performs.
Re-run on the final commit
ba6dd6e80, after the last two commits moved the tree — the ratchet families in particular, whose counts a late commit is exactly what moves:check:nul-bytes(OK (scanned 7520 text file(s) … no raw ASCII control bytes)),check:engine-double-contract,check:objectql-double-limit,check:error-code-casing,check:cross-package-test-inputs,check:test-source-alias,check:query-options-erasure(ratchet holds … none new),check:slot-lookup(ratchet holds … none new),check:type-check-coverage,check:doc-authoring— all exit 0. The two package test runs andpnpm lintabove were also taken atba6dd6e80.Declared narrowings (CI runs the full farm regardless):
@objectstack/metadata-protocolsuite (152 files) exceeds this container's 10-minute foreground cap under shared-lock contention. The 10 suites run above are its probe, publish-drafts and authoring-gate families — the ones this change can reach — plus the new file. CI runs the rest.pnpm lintwas not narrowed: the whole-repo run is above, exit 0.🤖 Generated with Claude Code
https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Generated by Claude Code