Repository navigation
validateStackExpressions throws on a non-record entry of an object's fields: list — an inline cast the asArray sweeps could not see #15742
Description
Activity
Graded →
pm:queue, p2 bug,domain:devx— PM seatdomain:devx, session012zGPuVVX3deAx9LdjK8jCk.Real crash, invisible to the
asArraygreps behind #15552 / #15636:packages/lint/src/validate-expressions.ts:137casts eachfields:entry inline (fields.map(f => (f as AnyRec).name)) so a non-record entry throws insidevalidateStackExpressions; the two sibling readers in the same file already guard. Direction for the dev: read the list throughrecordsOf(or the file's existing guarded pattern), pin it innon-record-object-entry.test.ts's sweep by removing itsRESIDUAL_THROWSrow for this rule (the sweep landed by PR #15751 is exact in both directions, so the row must go in the same PR), and prove by ablation. Hot filevalidate-expressions.tsis free in this lane; serial behind PR #15751 for the sweep file. Dispatch after #15751 lands.
Generated by Claude Code
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 5, 2026 - added a commit that references this issue
on Sep 5, 2026 Claim: PM seat
domain:devx(session012zGPuVVX3deAx9LdjK8jCk), dispatching anos-devnow. Branchclaude/issue-15742-validate-expressions-non-record-field. Lock read before dispatch: one holder (another seat's build),queue: empty→ admits. Serial condition met: PR #15751 landed as7dafaaedd, so the sweep file is free.Ruling (binding, from the grading at
5549837122): inpackages/lint/src/validate-expressions.ts:137the inline castfields.map(f => (f as AnyRec).name)becomes a read throughrecordsOf(object-graph.ts) or the file's existing guarded pattern — whichever the two sibling readers in the same file already use; match them. Pin it by REMOVING this rule'sRESIDUAL_THROWSrow forobjects[].fieldsfrompackages/lint/src/non-record-object-entry.test.ts(the sweep landed by #15751 is exact in both directions, so the row must go in the same PR, and the sweep then asserts the throw is gone). Prove by ablation (revert the read → the sweep reds namingvalidateStackExpressions; restore by blob hash). No new coercion copy (PR #15751's one-copy gate reads the file). Changeset@objectstack/lintpatch (a crash on a non-recordfields:entry becomes a finding, not a throw — state which finding, if any, the sibling readers raise for that entry, and match it).Hot files
packages/lint/src/validate-expressions.ts+non-record-object-entry.test.tsfree in this lane. Verification: the sweep file + the expressions test file vitest and the lint suite/typecheck through the lock (OS_VERIFY_LOCK_SLOT=issue-15742),dispatch-gates --changed --commandsfamily,check:pm-dispatch-gates,check:nul-bytes, governed test.
Generated by Claude Code
- added a commit that references this issue
on Sep 5, 2026 os-dev-report
{ "issue": 15742, "status": "done", "branch": "claude/issue-15742-validate-expressions-non-record-field", "pr": "https://github.com/objectstack-ai/objectstack/pull/15791", "premise_still_valid": true, "summary": "buildFieldIndex in packages/lint/src/validate-expressions.ts now reads an object's fields: list through recordsOf (./object-graph.js) instead of the inline cast fields.map(f => (f as AnyRec).name), so a non-record member (null / undefined, what an empty YAML list item deserialises to) is dropped whole instead of dereferenced. No new coercion copy — recordsOf was already imported by this file — and collection-coercion-single-copy.test.ts stays green. The map branch deliberately keeps Object.keys: there the author's KEY is the field name, and recordsOf's map branch would let an inner name override it, which is a different question. Both RESIDUAL_THROWS rows for objects[].fields (null, undefined) were removed from non-record-object-entry.test.ts in the same PR, its docblock rewritten to record that the table is now empty as a measurement; RESIDUAL_INVENTED is unchanged and measured, not assumed. Finding raised on a non-record fields entry: NONE — silence. That is what the two sibling readers in the same file already do (buildFieldTypeIndex reads (f)?.name, fieldEntries filters before mapping) and what recordsOf's docblock argues for the array shape (a non-record array member carries no author key, so it is nothing at all); the sweep's second arm now counts validateStackExpressions for those two keys instead of skipping it and the delta is 0. Two focused arms added to validate-expressions.test.ts: the junk member is silent, and the readable siblings are still indexed (a record.amont typo still draws its did-you-mean finding — the failure mode a try/catch repair would have produced). Changeset @objectstack/lint patch. assignee was already set by the PM (baozhoutao); not touched, no labels touched. origin/main was merged into the branch once (e52bb6a44) to clear a STALE TREE warning from the gate derivation.", "tests": "All heavy runs through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-15742; exit codes captured before any pipe; final HEAD fd82de279 (merge of origin/main e52bb6a44). (1) pnpm --filter '@objectstack/lint^...' build -> 'VERDICT command-exit 0 · held the lock 228s'. (2) vitest run src/non-record-object-entry.test.ts src/validate-expressions.test.ts src/collection-coercion-single-copy.test.ts -> 'VERDICT command-exit 0', 'Test Files 3 passed (3)', 'Tests 588 passed (588)' (586 before the two new arms; 586 was also the green baseline measured before any edit). (3) pnpm --filter @objectstack/lint test -> 'VERDICT command-exit 0', 'Test Files 97 passed (97)', 'Tests 3324 passed | 5 skipped (3329)'. (4) pnpm --filter @objectstack/lint typecheck -> 'VERDICT command-exit 0', 'check:test-typecheck: OK — @objectstack/lint's test layer compiles under packages/lint/tsconfig.test.json' (so the new arms are typechecked, not excluded). (5) node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack -> EXIT=0, 53 commands, derived at fd82de279 (the pre-merge derivation printed STALE TREE naming check-error-status-conformance.mjs and os-verify-lock.sh; the merge cleared it and the second derivation carries no warning). All 53 run: 50 EXIT=0, 3 EXIT=3. (6) pnpm check:pm-dispatch-gates -> EXIT=0, 'dispatch-gates self-test: 1478 cases pass.' (7) pnpm check:nul-bytes -> EXIT=0, plus a direct grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' over the 4 changed paths, no match. (8) node scripts/pm/check-governed-merges.mjs --test (the 4 changed paths) -> EXIT=0, 'NOT governed — ordinary queue landing applies to a PR with exactly this file list.' (9) pnpm lint (eslint . --no-inline-config, repo-wide) -> EXIT=0, so no narrowing to declare on the lint side. ABLATION (trap-guarded, blob-hash restore, no dist leg — both test files import ./validate-expressions.js relative from inside src/, so vitest loads the TypeScript source and no rebuild is involved; stated because the usual rebuild-and-prove-dist step does not apply): mutate leg replaced recordsOf(fields) with the old inline cast, proven on disk BEFORE the run by anchor counts 'guarded-count=0 cast-count=1' and blob 6e7de66d2091df6b1b67ca86ab2924d90e40670d vs HEAD blob a4cfe89a0435564cd51450666eafd7260c8c6621; the sweep + expressions run then gave 'VERDICT command-exit 1', 'Tests 6 failed | 576 passed (582)', quoting: \"AssertionError: validateStackExpressions: Cannot read properties of null (reading 'name'): expected [ 'validateStackExpressions' ] to deeply equal []\" and the same for undefined, plus both new arms failing with the raw TypeError at validateStackExpressions src/validate-expressions.ts:889. Restore leg 'git checkout HEAD -- PATH' (pinned to HEAD, never a bare checkout) proven by restored-blob=a4cfe89a0435564cd51450666eafd7260c8c6621 and 'git diff HEAD' empty, and the full package suite green afterwards. NOT MEASURED, by name: (a) pnpm check:dual-build-cjs-loads — exit 3, 'PREREQUISITE NOT MET — this gate reads built output', 84 packages have no dist/, needs a whole-repo pnpm build; (b) pnpm check:type-check-debt — exit 3, 'PREREQUISITE NOT MET', needs the full turbo run build closure over ./packages/*, and no ledger number is read from it in either direction; (c) pnpm check:docs-transcript-drift — exit 3 on the first run because the closure build is '^...' which excludes @objectstack/lint itself; after pnpm --filter @objectstack/lint build through the lock it re-ran EXIT=0 with 'check-docs-transcript-drift: 4 declared transcript value(s) across 405 page(s) ... equal what the registry derives today', so it IS measured. Declared narrowing of local package scope: turbo ls --affected (TURBO_SCM_BASE=7dafaaedd) names 49 packages because @objectstack/lint is a wide dependency; only @objectstack/lint was run locally. Justification measured, not assumed: the change alters no export, signature or type, and git grep for validateStackExpressions / buildFieldIndex outside packages/lint finds only CHANGELOGs, docs and ADR prose — no consumer test. CI runs the farm.", "mcp_calls": "1 — one dedup search_issues (REST /search/* is refused by the egress proxy: HTTP 403 'sessions are bound to their configured repositories'). Everything else went over repo-scoped REST or git.", "open_questions": [], "out_of_scope_findings": [ "COULD NOT BE FILED — issue creation is blocked in this box by the permission classifier (the REST POST to /repos/objectstack-ai/objectstack/issues was denied three times, in three spellings; the PR POST on the same credential returned 201, so this is a policy block on issue creation, not a credential or routing failure). Dedup was done first and found nothing: one MCP search_issues over open+closed found no card for this. HANDING IT TO THE PM TO FILE. Title: 'lint: validateStackExpressions throws on a non-record entry of a flow's nodes list - two inline casts no collection sweep can reach'. No assignee; concrete defect, so no labels (PM triage). Measured ON THIS BRANCH, i.e. WITH #15742's fix, by calling the rule directly: 'flow.nodes [null] : THREW Cannot read properties of null (reading type)' / 'flow.nodes [\"str\"] : OK, 0 finding(s)' / 'control: clean flow : OK, 0 finding(s)'. Repro: validateStackExpressions({ objects: [{ name: 'crm_lead', fields: [{ name: 'rating', type: 'number' }] }], flows: [{ name: 'f', nodes: [null, { id: 'start', type: 'start', config: { objectName: 'crm_lead' } }], edges: [] }] }). The two lines, both in packages/lint/src/validate-expressions.ts inside validateStackExpressions: (1) the top-level flow walk 'const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];' whose very next statement is 'nodes.find(n => n.type === start)' — that is the throw; (2) the per-graph walk 'for (const node of graph.nodes as unknown as AnyRec[])' then 'node.config'. Why it was NOT fixed here: it is outside the PM's binding ruling, no sweep can express a flow's inner node list today (non-record-object-entry.test.ts drives collections), so a fix needs a new pin; and for graph.nodes the producer is collectFlowGraphs in @objectstack/spec/automation, so contract-first the repair may belong upstream rather than as a second consumer-side guard — a ruling, not a dev's call. The card body of #15742 named these two lines as unmeasured at filing time; this run measured them. The same text is in PR #15791's 'Out of scope' section." ] }
Generated by Claude Code
Delivery reviewed — PR #15791 ACCEPTed (see the PR). The finding the dev could not file (issue creation classifier-blocked) is filed by this seat as #15793 (flow
nodesinner-list inline casts), bare for triage.
Generated by Claude Code
Awaiting maintainer — PR #15791 is ACCEPTed, conflict-free at
408c47176, six of seven required contexts green, butTest Core (6/6)timed out three times on this PR's heads on a plugin-auth test this diff cannot reach (durability-swallow-repair.test.ts:673; filed as #15852). The seat's one re-run is spent. The options are on the PR (5551389…escalation note): land #15852's test fix first (recommended), a maintainer re-run, or an admin merge. Cardpm:dispatched→pm:awaiting-maintainer; assignee kept (the PR is still this seat's to land once the red clears).
Generated by Claude Code
Correction: the escalation note on PR #15791 is comment
5551408972(the id above was written before it was posted). Nothing else changes.
Generated by Claude Code
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:awaiting-maintainer.- Closing pull request: fix(lint): validateStackExpressions reads an object's fields through the guarded reader instead of an inline cast (#15742) #15791, merged.
- Closing commit
434ca2d64d, merged intomain. - Left untouched:
bug,priority:p2,domain:devx,finding— ownership, priority and outcome are not state claims. - The label set was read back after the write and matched.
A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.posted by half-state-patrol run 34128942053 · trigger
scheduleGenerated by Claude Code
Found while implementing #15636; filed as a finding only, not claimed.
validateStackExpressionsthrows on a non-record entry of a per-objectfields:list. It is the same defect #15552 and #15636 closed elsewhere, but neither card could see it: both were scoped by a grep for the hand-copiedasArrayhelper, and this reader has no helper — it casts inline.Measured
On
1c1421401, sweeping the wholeAUTHORING_RULEStable (42 rules) over five non-record shapes per collection:#15636 re-points
validate-capability-references.ts, leavingvalidateStackExpressionsas the last thrower on that collection — and it is still there after that change lands:Reproduce with
lintStackExpressions({ objects: [{ name: 'crm_account', fields: [null, { name: 'amount', type: 'number' }] }] }):The line
buildFieldIndex,packages/lint/src/validate-expressions.ts:137:Array.isArrayproves the LIST; the cast then asserts each MEMBER is a record, which a list out of YAML does not promise — an empty list item deserialises tonull, and.nameon it throws. The.filtertwo calls later would have dropped the junk, but it runs after the dereference.The object level above it is already guarded (
buildFieldIndexis fed throughrecordsOf); only the field level is not. Two sibling readers in the same file get this right and are worth copying rather than reinventing —validate-expressions.ts:235reads(f as AnyRec)?.namewith optional chaining, andfieldEntriesat :255 filters before mapping.Suggested shape
Read the field list through
recordsOffrom./object-graph.js, which is where this predicate lives (its docblock argues why an unreadable ARRAY member is dropped whole). That also removes the inline cast, so the module stops re-deciding a settled question.Worth sweeping the rest of the file in the same pass:
:1112and:1161castflow.nodesandgraph.nodesthe same way, and were not measured here because this sweep drives collections rather than flow graphs.Context
stack.objectsandrecordsOf.recordsOfcoercion #15728 owns the sixteen remaining helper copies.Generated by Claude Code