fix(lint): read every flow node list through recordsOf - #16916
Conversation
`lintFlowPatterns` threw an uncaught `TypeError` on an ordinary flow whose `nodes:` list carried an empty YAML item: `Array.isArray` proves the list, never its members, and `nodes.find(n => n.type === 'start')` dereferenced the `null`. Seven readers across three modules held that spelling; all now coerce through `recordsOf`, the single home for this decision. The load-bearing half is which array is handed onward: `collectFlowGraphs` is transparent about members, so the COERCED array — not `flow.nodes` raw — is what it receives, or the crash relocates into `packages/spec` instead of going away. Its two `graph.nodes` readers are coerced too, because a nested region's list reaches them with only an `Array.isArray` behind it. `collectFlowVariableNames` guarded its `flow.variables` members and not its `graph.nodes` members, seven lines apart in one function; that half was reachable only at a region nest of exactly `MAX_REGION_DEPTH`. The three `validate-flow-template-paths.ts` readers were NOT throwing — they survived on an optional chain in the `.find` predicate, one character away from the reader that did. That half is a hardening. `recordsOf` gains no copy: the declaration count over `packages/lint/src` is unchanged at three (the canonical one plus the two ledgered). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…attributed Re-pointing the flow-node-list readers turned the sweep's `flows[].nodes[].config.body.nodes` arm red, demanding two throws that no longer happen. Reading the frames off the reverted tree showed why: neither was in `packages/spec`. `lintFlowPatterns` threw at its own `graph.nodes` reader (`lint-flow-patterns.ts:1529` on the reverted tree) and `validateStackExpressions` threw at `collectFlowVariableNames`' unguarded `graph.nodes` walk (`flow-variable-scope.ts:225`) — the two consumer sites this branch re-pointed. `collectFlowGraphs` forwards a non-record member of a nested list into the graph it yields rather than dereferencing it. So the rows had to go: the ratchet is exact in both directions and a row that demands a throw nobody performs is a lie. The docblock records the corrected attribution, and records just as plainly that this says nothing about whether the producer has a defect of its own reachable some other way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
📓 Docs Drift Check6 anchor(s) derived from 1 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 — 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 6b769944d8ff51dbdeed77442aeb382ef0c861ba && git checkout 6b769944d8ff51dbdeed77442aeb382ef0c861ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 34a76c4800d46f2aec1c136b65ff94e4087435c8 804192a5dc59403e41e30798b03bbd334633c78f && git checkout -B drift-repro 34a76c4800d46f2aec1c136b65ff94e4087435c8 && git merge --no-ff 804192a5dc59403e41e30798b03bbd334633c78f
node scripts/docs-audit/affected-docs.mjs --json 34a76c4800d46f2aec1c136b65ff94e4087435c8 |
ACCEPT in substance — PR #16916, reviewed against GitHub and the tree, ⛔ not against the reportFlip + arm withheld until the heavy legs report (this PR touches The two hard conditions — both verified by me, on the head, not from the report1. Which array reaches The coerced array is the one handed on. ⭐ And the dev's ablation is the part I want on the record: reverting only that half — keeping 2. ⭐ The unplanned finding, and it is the best thing in this reportEmptying the two rows this card did own turned the
⇒ Neither frame is in ⭐ Emptying all four rows is therefore forced, not a scope grab. A
⭐ Live proof of the gate that landed 20 minutes earlier
I added a line-start My declaration, for the record: the diff moves The hardening half, correctly labelledThe three
Filed, and correctly not fixed here#16910 — Two gates NOT MEASURED, declared rather than hidden
Checks
Generated by Claude Code |
|
RESIDUAL_THROWS |
|
|---|---|
origin/main now |
the two flows[].nodes rows — precisely the ones this PR removes (#16922 took out the two …config.body.nodes rows) |
| this head | {} |
⇒ The merged result is {}. ⛔ But it is not being resolved by picking a side: the dev is merging origin/main (a merge, ⛔ not a rebase, ⛔ no force-push) and re-running the sweep on the merged tree, because the ground under two of those arms moved.
⭐ A correction this PR's body owes, and it goes #16752's way
This PR's report recorded the two …config.body.nodes rows as misattributed — the frames it captured were at lint-flow-patterns.ts:1529 and flow-variable-scope.ts:225, neither in packages/spec. Those frames are real, and the report's own caveat («that does NOT prove #16752 has no producer-side defect at some other shape — I did not look») was the right hedge. #16922 looked, and there was one.
Both readings are true and compatible: the producer's declared element type lied — it forwarded a non-record member under a type promising FlowNodeParsed[] — and the consumers then dereferenced on the strength of that declaration. ⭐ A stack frame localises where a program died, ⛔ never where the contract was broken, which is exactly why frames alone could not settle this.
⇒ After both land, those two arms are green for two independent reasons. ⛔ Neither fix made the other unnecessary, and the PR body will say so rather than leaving "misattributed" as the last word. Recorded on #16752 at 5589434362.
I will re-flip and re-arm once the merge is green. ⛔ The ACCEPT above stands — nothing in the diff's substance changed.
Generated by Claude Code
One conflict, in `packages/lint/src/non-record-object-entry.test.ts`'s
`RESIDUAL_THROWS`. Both sides emptied it from opposite ends: `main` (the
producer repair) removed the two `flows[].nodes[].config.body.nodes` rows and
kept the two `flows[].nodes` ones; this branch (the consumer repair) removed
all four. The merged table is `{}` — `main`'s two remaining rows are exactly
the ones this branch's coercion retires — and the sweep is re-run rather than
reasoned about.
The docblock is resolved to carry BOTH accounts rather than either alone. The
producer's declared `FlowNodeParsed[]` did lie about what it returned, and the
two consumer readers did dereference without a guard; measured on the tree
before either fix, the graph-shaped arms threw from the consumer frames, and
they are green now for two independent reasons. Neither repair makes the other
unnecessary, and the note says so, so a later reader cannot conclude one of
them was redundant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Part of #16751
Clause-②: no
Re-declared by the⚠️ This line was added once at 16:29Z and was lost when the body was rewritten during the merge-conflict repair — the declaration lives in the body, so an ordinary body edit destroys it silently. Filed as a finding. The judgement is unchanged: the diff moves
domain:devxPM seat at 18:1xZ.packages/lint/src/**, neverpackages/spec/src/**; it puts no new key on any published payload and narrows nothing an author may write — it stops an existing reader throwing on input the linter already accepted in principle. ⇒ the axis stands down and thepatchgrade holds.lintFlowPatternscrashed on an ordinary flow. A YAMLnodes:list item left empty deserialises tonull, and the rule readnodes.find(n => n.type === 'start')off a list it had onlyArray.isArray-checked, so an author's own metadata turnedobjectstack validateinto an uncaughtTypeErrorout of a function contractually typed(stack) =Finding[]. Seven readers across three modules held that spelling; all now coerce throughrecordsOf.mainat804192a5dc— one conflict, and it moved two of the measurements below#16922 (the
#16752producer repair) landed at 17:00:41Z, after this branch was cut, and it touched this PR's pin file. The conflict was inRESIDUAL_THROWS, and both sides emptied it from opposite ends:flows[].nodesrowsflows[].nodes[].config.body.nodesrowsorigin/main(producer repair)RESIDUAL_THROWS = {}Resolved to
{}, every other hunk kept from both sides, and then re-run rather than reasoned about:pnpm --filter @objectstack/lint testis green on the merge, 3583 tests, with no arm suppressed.The docblock is resolved to carry both accounts. My earlier note called the
#16752rows "misattributed"; that was half the story and is corrected in the file and here.⭐ Correction: the graph-shaped arms are green for TWO independent reasons
Measured on the tree before either fix, the two
flows[].nodes[].config.body.nodesthrows carried consumer frames —lintFlowPatternsat its owngraph.nodesreader,validateStackExpressionsatcollectFlowVariableNames' unguardedgraph.nodeswalk — neither insidepackages/spec. That reading was correct, and it was also incomplete: #16922 found a real producer defect behind it, namely that whatcollectFlowGraphsreturned did not match its declaredFlowNodeParsed[].Both are true and neither repair makes the other unnecessary. The producer's declared element type lied, AND the consumers dereferenced without a guard. Remove the producer fix and the junk member is handed out again to every other consumer; remove the consumer coercion and these two readers are back to trusting a declared element type. ⛔ A later reader must not take my note as evidence #16922 was unnecessary — it was not.
⭐ Correction: the onward-handoff ablation no longer reproduces, and that is #16922's doing
This is the card's "non-obvious half", so both measurements are given rather than the convenient one.
On the pre-merge tree,
collectFlowGraphsFORWARDED a non-record member into the graph it yielded:and an ablation of exactly the wrong fix — coerce for the local
.find(), handflow.nodesRAW onward, restore the bare cast at bothgraph.nodesreaders — made the crash relocate rather than disappear:On the merged tree the same ablation, byte-identical, throws nothing, because #16922 now drops the member at the producer:
So: the handoff condition the card raised was load-bearing and is now belt-and-braces for this shape. It stays in the shipped code on contract grounds rather than crash grounds —
collectFlowGraphsdeclaresFlowNodeParsed[], so handing it raw authored metadata is calling it out of contract, and a consumer should not depend on another package's filter for its own totality. ⛔ What this PR does not claim is that the handoff is what removes the crash today. It was, before 17:00:41Z.Both ablation legs proved the mutation on disk (anchor
grep -cbefore/after plus the blob hash moving off81e48ead) and both restore legs verified byte-identical (git checkout HEAD -- path, hash back to81e48ead,git diff HEADempty), under a trap on EXIT/INT/TERM with an absolute repo root. Nodist/was involved: the harness importspackages/lint/src/*.tsdirectly undertsx.Before / after, on the merged tree
The harness is temporary and is not in the diff.
The repro is the card's, verbatim:
Row 1 is the whole card, and it is not covered by #16922:
flow.nodesis a list the rule reads itself, before any producer sees it. Row 6 was reachable only at a region nest of exactlyMAX_REGION_DEPTH; it now cannot throw at any depth.The three
validate-flow-template-paths.tssites were NOT throwingStated plainly, because the diff does not show it:
boundObjectOf,declaredExpandOfand the per-flow start lookup invalidateFlowTemplatePathsnever crashed and are a hardening, not a bug fix. Row 7 above isthrew=NOon both sides.They survived on one character. The reader that threw is
nodes.find((n) => n.type === 'start'); these three arenodes.find((n) => n?.type === 'start'). Nothing maintained that difference, and the optional chain reads as redundant beside theArray.isArraydirectly above it — so the next person to tidy it deletes it and the defect is back. The coercion is made where it has a home and the now-genuinely-redundant?.goes with it. (The?.configafter the.find()at the third site stays:.find()really can returnundefined.)recordsOfgains no copycollection-coercion-single-copy.test.tscounts declarations of the shape(v: unknown): AnyRec[]overpackages/lint/src/*.ts:Verification, re-run in full on the merged head
804192a5dcExit codes captured before any pipe; heavy legs through
scripts/pm/os-verify-lock.sh, verdicts read off its printedVERDICTline.pnpm install --frozen-lockfile+pnpm --filter '@objectstack/lint...' buildpnpm-lock.yamlandpackages/spec)pnpm --filter @objectstack/lint testTest Files 102 passed (102),Tests 3583 passed (3583)pnpm --filter @objectstack/lint typecheckpackages/specregion-normalization.test.ts(#16922's own pin, on this merge)scripts/pm/dispatch-gates.mjs --ran)pnpm lint(repo-wideeslint . --no-inline-config)Two of the 55 exited 3 = PREREQUISITE NOT MET, which their own text calls "NOT a pass" and "nothing was measured" — both want a whole-repo
pnpm buildfirst, which is CI's job, not a finding:check:dual-build-cjs-loads(75+ packages have nodist/) andcheck:type-check-debt(--re-measurerefuses without the built closure). Declared to CI, unchanged from the pre-merge run.Files
packages/lint/src/lint-flow-patterns.ts—:456,:1426(and the array handed on tocollectFlowGraphs),:1522packages/lint/src/flow-variable-scope.ts—:222, plus therecordsOfimportpackages/lint/src/validate-flow-template-paths.ts—:256,:274,:298(hardening)packages/lint/src/non-record-object-entry.test.ts—RESIDUAL_THROWSemptied, docblock resolved to carry both accounts.changeset/lint-flow-node-list-recordsof.md— patch on@objectstack/lintpackages/lint/src/object-graph.tsis untouched:recordsOfneeded no change.packages/specis untouched by this branch; everything it contains from there arrived through the merge.验收备注
lintFlowPatternsthrows on a non-record member of a flow'sedgeslist — the sibling list #16751 did not cover #16910 —flows[].edgesis the same defect one list over, and it is live:edges: [null, valid]throwsCannot read properties of null (reading 'label')atscanErrorLabelledEdges(lint-flow-patterns.ts:691) on a tree that already carries this repair. The sweep has noflows[].edgesarm at all, so nothing covers that list in either direction. Deliberately NOT fixed here: this card and its dispatch are scoped to node lists, and the edges repair owes a new sweep arm, which is a different review.regionNodesOf(lint-flow-patterns.ts) carries the sameArray.isArray+ cast shape for a region's node list, but every one of its three call sites guards each member withif (!child || typeof child !== 'object') continuebefore reading it, so it cannot throw today. That is a guard standing somewhere the reader does not promise it — the shapeUNGUARDED_ALLOWANCEexists to date — but it is not a defect, not a contract violation and not an authoring trap, so it is a note rather than a card. Whoever takes lint:lintFlowPatternsthrows on a non-record member of a flow'sedgeslist — the sibling list #16751 did not cover #16910 will be in this function's file and can decide.graph.edgesatlint-flow-patterns.tskeeps the double cast. It is part of lint:lintFlowPatternsthrows on a non-record member of a flow'sedgeslist — the sibling list #16751 did not cover #16910's surface, not a separate item; recorded here so the two are not fixed apart.Generated by Claude Code