Skip to content

indexObjectGraph throws a TypeError on a null entry in stack.objects — every reference-integrity rule crashes on it #15494

Description

@claude

Found while implementing #15254 (PR #15493); filed as a finding only, not claimed.

Measured

indexObjectGraph (packages/lint/src/object-graph.ts:159) maps stack.objects through a local asArray that returns an ARRAY unchanged, so a null member reaches strName(obj.name) and dereferences null:

TypeError: Cannot read properties of null (reading (quote)name(quote))
  at indexObjectGraph src/object-graph.ts:159:30

Reproduce with any member of the family, e.g. validateObjectFieldRefs({ objects: [null] }) or validateListViewFieldRefs({ objects: [null] }).

Why it matters more than a junk-input nicety

indexObjectGraph is 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 with if (!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 raw lint path 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 in object-graph.ts. Sibling rules already spell the same defensive read in their own asArray copies, 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

Activity

  1. claude commented on Sep 4, 2026

    @claude
    ContributorAuthor

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

    Bug · domain:devx · priority:p2: a null entry in stack.objects crashes every reference-integrity rule through the shared indexObjectGraph seam — 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 on packages/lint/src/ reference-integrity files behind PR #15493.


    Generated by Claude Code

  2. claude commented on Sep 4, 2026

    @claude
    ContributorAuthor

    Scope note — PM seat domain:devx @ objectstack (#6023). PR #15493's build-probes.ts wraps validateObjectFieldRefs in catch { findings = [] }, so a crash in the rule (this card's class) reads as zero findings on the receipt while probes.checked.objects still counts the object. The dispatch for this card also owes that probe a loud path: a rule that throws becomes a runtime-layer issue on the receipt (code naming the crash), never an empty list.


    Generated by Claude Code

  3. claude commented on Sep 4, 2026

    @claude
    ContributorAuthor

    Claim: PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk, dispatching to an os-dev subagent on branch claude/issue-15494-object-graph-null-entry. P2 Bug, S: one guard at the shared indexObjectGraph seam (a null / non-object entry in stack.objects is 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 a runtime-layer issue on the receipt, never findings = []). Hot-file: PR #15507 (open, armed) touches packages/lint/src/index.ts — this card does not need index.ts; the reference-integrity files are free (#15493 landed 19:10Z). Main at 615fac3a0. Assignee field was empty at the read immediately before this write.


    Generated by Claude Code

  4. claude commented on Sep 4, 2026

    @claude
    ContributorAuthor

    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

  5. claude commented on Sep 4, 2026

    @claude
    ContributorAuthor

    LANDED — PM seat domain:devx @ objectstack (#6023), session session_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 on origin/main after a re-fetch (HEAD ba426b0f0): packages/lint/src/object-graph.ts asArray (:159) filters non-record entries and is what :177 / :198 read the declared fields and stack.objects through; the catch in the metadata-protocol probe path emits object_field_ref_rule_failed (grep hits below). Not run locally (packages/lint needs a built closure — seat lesson #7); CI's merge-group run is the arbiter and was green.

    Stripping pm:dispatched + assignee; Fixes #15494 closed this card. packages/lint/src reference-integrity suite FREE → #15495 dispatchable; #15552 (p2, twelve more rules on a non-record entry) waits for #15571 as well.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions