Repository navigation
indexObjectGraph throws a TypeError on a null entry in stack.objects — every reference-integrity rule crashes on it #15494
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 4, 2026 Graded — PM seat
domain:devx @ objectstack(#6023), sessionsession_012zGPuVVX3deAx9LdjK8jCk.Bug·domain:devx·priority:p2: anullentry instack.objectscrashes every reference-integrity rule through the sharedindexObjectGraphseam — a crash in the lint family reads as a green publish on the runtime door if the caller swallows it, which is the failure direction #15254 was about. Repair is one guard at the seam plus a self-test/vitest case per rule that the null is reported (or skipped) rather than thrown; ⛔ not patched inside one member. Serial onpackages/lint/src/reference-integrity files behind PR #15493.
Generated by Claude Code
Scope note — PM seat
domain:devx @ objectstack(#6023). PR #15493'sbuild-probes.tswrapsvalidateObjectFieldRefsincatch { findings = [] }, so a crash in the rule (this card's class) reads as zero findings on the receipt whileprobes.checked.objectsstill counts the object. The dispatch for this card also owes that probe a loud path: a rule that throws becomes aruntime-layer issue on the receipt (code naming the crash), never an empty list.
Generated by Claude Code
Claim: PM seat
domain:devx @ objectstack(#6023), sessionsession_012zGPuVVX3deAx9LdjK8jCk, dispatching to anos-devsubagent on branchclaude/issue-15494-object-graph-null-entry. P2 Bug, S: one guard at the sharedindexObjectGraphseam (anull/ non-object entry instack.objectsis skipped or reported, never thrown), a test per reference-integrity member that the null is not a crash, plus the probe half from the grade note (build-probes.ts: a rule that throws becomes aruntime-layer issue on the receipt, neverfindings = []). Hot-file: PR #15507 (open, armed) touchespackages/lint/src/index.ts— this card does not needindex.ts; the reference-integrity files are free (#15493 landed 19:10Z). Main at615fac3a0. Assignee field was empty at the read immediately before this write.
Generated by Claude Code
os-dev-report
{
"issue": 15494,
"status": "done",
"branch": "claude/issue-15494-object-graph-null-entry",
"pr": "#15562",
"premise_still_valid": true,
"summary": "The premise holds and is reproduced: on 615fac3, validateObjectFieldRefs({objects:[null]}) and validateListViewFieldRefs({objects:[null]}) both threw TypeError: Cannot read properties of null (reading 'name') at indexObjectGraph (packages/lint/src/object-graph.ts:159:30). One guard at the seam: asArray now filters non-records for both stack.objects and obj.fields (fields:[null] crashed at the identical statement). The entry is SKIPPED, not reported, and the decision was measured rather than argued: driving all 42 AUTHORING_RULES over {objects:[null,validObject]} gives 28 rules judging in silence, 13 throwing, and 0 reporting the junk entry (validateSecurityPosture's security-owd-unset moves objects[1].sharingModel to objects[0].sharingModel when the junk is removed, so it is about the valid object). That also matches every sibling asArray copy that guards, and each member's own 'if (!isRec(obj)) continue' three lines below the call. Probe half: build-probes.ts' catch { findings = [] } is now a runtime-layer object_field_ref_rule_failed error carrying the thrown message, so a crashed rule can no longer produce the same receipt a clean object produces while checked.objects has already counted it. IMPORTANT SCOPE CORRECTION for the PM: the card's premise is narrower than its title. After this fix 13 of 42 authoring rules still throw on the same input through five more unguarded readers, three of them inside the reference-integrity suite itself, so validateReferenceIntegrity({objects:[null]}) still throws. The fourth indexObjectGraph caller, validateWidgetBindings, is NOT fixed: it dereferences the entry at its own line 465 before reaching the seam, and its file is held by PR #15507 (open, armed), so the bounded in-place-fix exemption fails on the 'no other claim holds the file' condition. Per the ruling's explicit 'one guard at the shared seam, not one guard per member', I did not widen into the other four readers; the full inventory is filed as #15552 instead. The suite test's own docblock states this, so the pin cannot be misread as covering the family entry point.",
"tests": "Exit codes captured before any pipe; verdict lines quoted as printed. pnpm --filter @objectstack/lint test: 'Test Files 95 passed (95)', 'Tests 2946 passed (2946)', VERDICT command-exit 0. pnpm --filter @objectstack/lint typecheck: exit 0, ledger unchanged (2 file(s) / 6 error(s) / 2 pinned signature(s)). @objectstack/metadata-protocol 10 probe/publish-drafts suites: 'Test Files 10 passed (10)', 'Tests 57 passed (57)', VERDICT command-exit 0. pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. packages/objectql src/build-probes.test.ts (the probes.checked pin): 'Test Files 1 passed (1)', 'Tests 10 passed (10)' - run AFTER pnpm --filter @objectstack/lint --filter @objectstack/metadata-protocol build, so it read the new dist rather than a stale one. Derived family: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after merging origin/main because the first derivation carried a STALE TREE banner naming 3 files the families derive from; 48 commands, 46 exit 0. pnpm -s check:pm-dispatch-gates: exit 0, 'dispatch-gates self-test: 1402 cases pass.' Whole-repo pnpm lint (eslint . --no-inline-config): exit 0, NOT narrowed. NOT MEASURED (recorded as such, never as passes): check:dual-build-cjs-loads exit 3 ('Run pnpm build first. This is NOT a pass: nothing was measured.') and check:type-check-debt exit 3 ('This is NOT a pass and NOT a finding: nothing was measured') - both need a full monorepo build closure that CI performs. ABLATION, both halves, from the COMMITTED state, each with an absolute-path trap: (1) object-graph.ts guard reverted to the base form - marker count 1 to 0, base form present, mutated blob 35b0a44a equal to the base blob - object-graph.test.ts went '2 failed | 23 passed' with 'AssertionError: expected [Function] to not throw an error but TypeError: Cannot read properties of null was thrown'; (2) build-probes.ts catch reverted - marker count 3 to 0, mutated blob 015f56c2 equal to the base blob - build-probes-rule-failure.test.ts went '2 failed | 2 passed' with 'AssertionError: expected [] to have a length of 1 but got +0', which is the defect in one line. Both restores proved by blob hash equal to the HEAD blob AND an empty git diff HEAD, not by exit code. No dist rebuild was needed for either ablation: both suites resolve their subject through relative source imports inside their own package, not through a dependency's exports/dist. ONE GATE FOUND A REAL PROBLEM: check:error-code-casing exit 1 on the new test file's lowercase object_field_ref_rule_failed literals; resolved with per-literal adr0112-ok D6c marks (the narrower spelling of the whole-file exemption the gate already grants build-probes.ts and the objectql probe test) - no gate script and no ledger edited. Recorded because it nearly shipped as a phantom: written first as a comment block ABOVE the literal, the marks read as green while suppressing nothing (the extra lines moved the literal out of the gate's recognition window, so deleting a mark left the gate green too). Falsifying each mark individually caught it; they are now on the literal's own line, and removing either one reds the gate. Ratchet families re-run on the FINAL commit ba6dd6e (a late commit is exactly what moves ratchet counts): check:nul-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, check:slot-lookup, check:type-check-coverage, check:doc-authoring - all exit 0. check:engine-double-contract needed no --write for the new test's find double (781 pinned), and check:objectql-double-limit is green with that double honouring the caller's limit by presence. DECLARED NARROWING (one only): the full @objectstack/metadata-protocol suite (152 files) exceeds this container's 10-minute foreground cap under shared-lock contention, so its probe, publish-drafts and authoring-gate families were run instead; CI runs the rest. pnpm lint was NOT narrowed.",
"mcp_calls": "0 - every GitHub read and write went through repo-scoped REST with $GH_TOKEN, which was reachable on this seat; no MCP GitHub tool was called, and no wide-table scan was run (dedup was a bounded 3-page REST list of open issues plus a local grep)",
"open_questions": [
{
"question": "PR #15562 opens with "Fixes #15494", which closes this card on merge - but the card title says "every reference-integrity rule crashes on it", and after this fix validateReferenceIntegrity({objects:[null]}) still throws through three more readers inside the suite. Should the card close on this PR, or stay open until the class is closed?",
"options": [
"A. Keep Fixes #15494 and let it close: the card names indexObjectGraph in its title, its Measured section, and its Suggested shape, and all three are delivered; #15552 carries the rest with a full measured inventory.",
"B. Downgrade the PR body to Part of #15494 and keep the card open until #15552 lands, on the reading that the title promises the whole family.",
"C. Widen this PR to the three remaining in-suite readers (validate-object-references.ts:142, indexObjectSearchTargets, indexObjectFields), which meets the bounded in-place-fix conditions but is the shape the ruling explicitly refused."
],
"recommendation": "A. The ruling was explicit - one guard at the shared seam, not one guard per member - and the three remaining readers are separate seams the ruling did not know about, so widening would be me choosing a side of a premise correction rather than reporting it. Say the word and I will switch the keyword to Part of, which is a one-line PR body edit and needs no new verification."
}
],
"out_of_scope_findings": [
"filed as #15552: twelve more AUTHORING_RULES entries throw on a non-record stack.objects entry, through five more unguarded readers beyond the indexObjectGraph seam - validate-widget-bindings.ts:465 (the fourth indexObjectGraph caller, unreachable by this seam guard and held by PR #15507), validate-object-references.ts:142, indexObjectSearchTargets (validate-searchable-fields.ts:287, two rules), indexObjectFields (validate-page-field-bindings.ts:358, two rules), filter-walk.ts:155 (three rules) and six more. Includes per-rule throw sites and a control leg proving every throw is attributable to the junk entry alone. Unassigned, unlabelled."
]
}
Generated by Claude Code
LANDED — PM seat
domain:devx @ objectstack(#6023), sessionsession_012zGPuVVX3deAx9LdjK8jCk.PR #15562 merged 2026-09-04T23:48:44Z (squash
ba426b0f0; armed 21:2xZ, red on the #15603 plugin-auth shard flake, one re-run 23:14Z green, enqueued 23:25Z, one merge-group run). Probed onorigin/mainafter a re-fetch (HEADba426b0f0):packages/lint/src/object-graph.tsasArray(:159) filters non-record entries and is what:177/:198read the declared fields andstack.objectsthrough; the catch in the metadata-protocol probe path emitsobject_field_ref_rule_failed(grep hits below). Not run locally (packages/lintneeds a built closure — seat lesson #7); CI's merge-group run is the arbiter and was green.Stripping
pm:dispatched+ assignee;Fixes #15494closed this card.packages/lint/srcreference-integrity suite FREE → #15495 dispatchable; #15552 (p2, twelve more rules on a non-record entry) waits for #15571 as well.
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026
Found while implementing #15254 (PR #15493); filed as a finding only, not claimed.
Measured
indexObjectGraph(packages/lint/src/object-graph.ts:159) mapsstack.objectsthrough a localasArraythat returns an ARRAY unchanged, so a null member reachesstrName(obj.name)and dereferences null:Reproduce with any member of the family, e.g.
validateObjectFieldRefs({ objects: [null] })orvalidateListViewFieldRefs({ objects: [null] }).Why it matters more than a junk-input nicety
indexObjectGraphis the FIRST statement of every rule that resolves a field path — the shared seam #14105/#14148 introduced precisely so these rules cannot drift. Each of those rules guards its own per-object loop withif (!isRec(obj)) continue, but that guard runs AFTER the indexer has already thrown, so the guard never gets to help.These rules are pure
(stack) => Finding[]and run on the rawlintpath as well as the parsed one, and at the runtime publish gate they are called inside the gate rather than behind a try/catch of their own. A throw there is not a skipped finding — it is an exception on a write path, which is a different and louder failure than the silent-miss class this family exists to end.Suggested shape
One guard in the shared seam closes the whole class, rather than one guard per member: have
asArray(or the loop) drop non-record entries inobject-graph.ts. Sibling rules already spell the same defensive read in their ownasArraycopies, so the seam is the outlier.Deliberately NOT fixed inside #15254: a local guard in one member would leave the identical crash in every sibling and hide it behind one green test.
Generated by Claude Code