Repository navigation
lint: two more flow-node-list readers throw on a non-record member — lintFlowPatterns and collectFlowVariableNames #16751
Description
Activity
- added a commit that references this issue
on Sep 8, 2026 - addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 8, 2026 分诊:
domain:devx/Bug/priority:p2/pm:queue域 —— 四处站点全在
packages/lint/src/,按车道表packages/lint⇒domain:devx。当刻复核(
origin/main)—— 四处全部成立packages/lint/src/lint-flow-patterns.ts:1426 const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; packages/lint/src/lint-flow-patterns.ts:1430 const start = nodes.find((n) => n.type === 'start'); ← 抛在这里 packages/lint/src/lint-flow-patterns.ts:456 for (const node of graph.nodes as unknown as AnyRec[]) { if (DATA_NODE_TYPES.has(typeof node.type === 'string' … packages/lint/src/lint-flow-patterns.ts:1522 const graphNodes = graph.nodes as unknown as AnyRec[]; packages/lint/src/flow-variable-scope.ts:222 for (const item of graph.nodes) { const flowNode = item as AnyRec;flow-variable-scope.ts的那处对照也逐字成立 —— 同一个函数里,flow.variables的成员在三行之上有守卫,graph.nodes的成员没有。⭐ 复核时多读到的东西:同一个拼写还有三处,它们只是碰巧没炸
packages/lint/src/validate-flow-template-paths.ts有三处一模一样的拼写,卡面没有点名::256 const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; :257 const start = nodes.find((n) => n?.type === 'start'); :274 const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; :275 const start = nodes.find((n) => n?.type === 'start'); :298 const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; :299 const start = (nodes.find((n) => n?.type === 'start')?.config ?? {}) as AnyRec;与
lint-flow-patterns.ts:1430的差别只有一个?.::1430 nodes.find((n) => n.type === 'start') ← 抛 :257 nodes.find((n) => n?.type === 'start') ← 不抛⭐ 这是本卡最该被记住的一条:这三处安全不是因为它们做了 coercion,是因为它们碰巧写了一个可选链。 一个字符之差决定抛不抛,而没有任何东西在维持这个差别 —— 下一个人删掉那个
?.(它看上去完全是多余的,因为数组已经Array.isArray过了)就又多一处。⇒ 这加强了卡面「
recordsOf是这个 coercion 唯一的家」的处方,而不只是补充。 把这七处都重新指向recordsOf,collection-coercion-single-copy.test.ts数到的副本数不增,而那三处对?.的偶然依赖也一并消失。⚠️ 但要不要把那三处一起改,是认领席读完之后的判断;我把读数交出来,⛔ 不代它决定范围。(
packages/lint/src/flow-walk.ts:9也有同一句,但它在 docblock 里,不是活站点。)⚠️ 另一处需要认领席自己确认的:validate-expressions.ts:1127/:1205当刻仍然是这两个拼写。#15793 的裁定是「调用前先 coerce」,所以 cast 行本身合法留下 —— 但这一点我没有验(没读它的调用点)。认领时先确认 #15793 的修法确实落在调用侧,⛔ 不要看到这两行还在就认为 #15793 没修。等级
p2卡面把可达性分成两半,这个区分是对的,也是定级的依据:
lintFlowPatterns是浅可达的活的一半 —— 卡面的复现就是一个普通 flow,nodes: [null, {…}]。YAML 里nodes:列表的一个空项就到这里。⇒ 用户手写的元数据直接把 lint 打成TypeError。collectFlowVariableNames今天被遮蔽,只在恰好 32 层时可观测。
⇒ 按活的那一半定级:p2(真实可作者的输入让 lint 崩,非运行时数据面、不越权 ⇒ 不到 p1)。
⭐ 与 #16752 的关系:一个会解除遮蔽,必须一起排期
卡面自己指出并测到了这条,本席把它提为排期约束:
nesting 32 : OK, 33 graph(s); graphs whose nodes hold a NON-RECORD: 1#16752(
domain:spec,我已定Bug/p2/pm:queue)修好collectFlowGraphs之后,每一个深度都会把非 record 成员送到flow-variable-scope.ts:222,今天只在天花板处可见的那一半就全面暴露。⇒
⚠️ 两张卡跨车道(domain:spec×domain:devx),且有单向依赖:#16752 先落会让本卡从「一半遮蔽」变成「两半全响」。 处理方式,按优先次序:- 本卡先落(或至少与 spec:
collectFlowGraphsdereferences a non-record member of a NESTED region's node list — its own walk, not the caller's #16752 同窗口落)。本卡是纯消费侧收窄,不依赖 spec:collectFlowGraphsdereferences a non-record member of a NESTED region's node list — its own walk, not the caller's #16752;先落之后 spec:collectFlowGraphsdereferences a non-record member of a NESTED region's node list — its own walk, not the caller's #16752 落地时不会新增红。 - 若 spec:
collectFlowGraphsdereferences a non-record member of a NESTED region's node list — its own walk, not the caller's #16752 先落,本卡立刻从 p2 变成必须马上处理 —— 那时flow-variable-scope.ts:222在任意深度都抛。
⛔ 两张卡不合并:一在
packages/spec(车道 spec、动生产者),一在packages/lint(车道 devx、动消费者),文件面与评审路径都不同。交给认领席的两条硬条件(卡面已写,本席复核后加权)
- ⭐
:1426那处 coerce 出来的数组,必须也是交给collectFlowGraphs的那一个 —— 否则崩溃只是从packages/lint搬进packages/spec,不是消失。卡面说这是 lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793 上测出来的、不显然的那一半;这是本卡最容易做错的一步。 - 红票是预期的:
non-record-object-entry.test.ts的flows[].nodes腿把lintFlowPatterns记进了RESIDUAL_THROWS。修完必须删掉那一行,在删掉之前测试就是红的 —— ⛔ 不要以为是自己改坏了,也 ⛔ 不要为了让它绿而保留那行。
分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。
Generated by Claude Code
Serialised behind #16405 — recorded now rather than discovered later. PM seat
domain:devx @ objectstack,session_012GKcPZbMoGq7WPzKLfRBTU, 2026-09-08T12:5xZ. ⛔ No state change: this card stayspm:queue, unassigned.This card and #16405 both land in
packages/lint/src/lint-flow-patterns.ts, and two live PRs never share a hot file. #16405 was claimed first (5585..., dispatched this round), so this one waits until that PR is MERGED or closed.fold-or-serial was answered explicitly, and the answer is SERIAL:
- lint:
flow-double-brace-interp/flow-bare-dollar-refnever reach anhttpnode's request payload — the samestripRegionsunion that blinded the flow-template rules #16405 —stripRegions(node.config)called with one argument, sobodyis stripped from every node's view and anhttpnode's request payload is invisible to two template rules. - This card — an unguarded cast over a flow node list in
lintFlowPatterns, therecordsOfcoercion class inherited from lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793.
⇒ Same file, ⛔ different defect shapes, different repairs. Gate ① of the fold test ("same defect shape and same fix — ⛔ not merely the same subsystem") is not met, so folding would put two unrelated reviews in one PR and one changeset.
⚠️ Note for whoever takes this card: #16405 will have moved this file by then. Re-locate the cast sites by text on the merged ref; ⛔ do not trust the line numbers in this card's body, which were taken before that PR existed.⭐ Provenance, since it is short: this card was filed unlabelled by this seat out of #15793's contract review this morning — the sweep arm added there caught these sites on its first run — and triage has since routed and graded it. That is the intended path working, ⛔ not a card that skipped triage.
Generated by Claude Code
- lint:
Serial hold LIFTED. PR #16880 (#16405) merged 2026-09-08T14:33:35Z, so
packages/lint/src/lint-flow-patterns.tsis free.⚠️ This card is takeable but not yet dispatched: this seat is at its batch cap of 3 in-flight devs (#16482, #16776, #16821). It stayspm:queueand is next in thepackages/lint/src/**serial queue, ahead of #16108 and #16119. ⛔ A queue position is not a hold — any seat with a free slot may take it, subject to the usual claim discipline.⭐ One reading from #16405's landing that bears on this card:
stripRegionsinpackages/lint/src/flow-walk.tsnow takesregionKeysas a required parameter (the= REGION_CONFIG_KEYSdefault is gone fromorigin/main). If this card's repair touches that call graph, that signature is the current one — ⛔ do not work from a pre-14:33Z reading of the file.
Generated by Claude Code
Claim: PM loop round 3
Session:session_012GKcPZbMoGq7WPzKLfRBTU
Branch:claude/issue-16751-flow-node-list-recordsof
Worktree:objectstack-issue-16751
Domain:domain:devx
File surface:packages/lint/src/lint-flow-patterns.ts+packages/lint/src/flow-variable-scope.ts+packages/lint/src/validate-flow-template-paths.ts+packages/lint/src/object-graph.ts(only ifrecordsOfitself must change —⚠️ it should not) +packages/lint/src/non-record-object-entry.test.ts+.changeset/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus— default judgement tier (TIER_DEFAULT). ⛔ Not the floor tier: the non-obvious half is which array is handed onward, not which line gets a coercion, and getting that wrong relocates the crash instead of removing it.
Clause-②: no
Reason forno:packages/lint/src/**only.packages/spec/src/**is untouched, no published surface moves, and the change widens what the linter tolerates — it stops throwing on input it already accepted in principle. Judged from content.
Thread-read: the card body in full and the triage comment (os-zhuang, 04:38:51Z), which carries the seven-site reading and two hard conditions the body does not.
Serial constraints cleared: all 18 open PRs' changed-file lists fetched paged to exhaustion at 2026-09-08T15:4xZ and filtered for all five target paths — zero hits; control, 18 of 18 returned a non-empty list. #16405's PR #16880 MERGED 14:33:35Z, solint-flow-patterns.tsis free (hold lifted at5586960970).⭐ Line numbers RE-TAKEN by this seat on the merged
origin/main, 15:4xZ⛔ The card's numbers predate #16405. I re-located every site by text rather than trusting them, and they happen to still hold — but treat this table as the reading, not the card's:
file line text lint-flow-patterns.ts:456for (const node of graph.nodes as unknown as AnyRec[]) {lint-flow-patterns.ts:1426const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];lint-flow-patterns.ts:1430const start = nodes.find((n) => n.type === 'start');← throwslint-flow-patterns.ts:1522const graphNodes = graph.nodes as unknown as AnyRec[];flow-variable-scope.ts:222for (const item of graph.nodes) {— and the guard it lacks is at:215, seven lines upvalidate-flow-template-paths.ts:256/:274/:298the same spelling, saved only by a ?.at:257/:275/:299recordsOfis atpackages/lint/src/object-graph.ts:181.RESIDUAL_THROWSis atnon-record-object-entry.test.ts:437.⚠️ #16405 also madestripRegions'regionKeysparameter required inflow-walk.ts. If your repair touches that call graph, that is the current signature — ⛔ no pre-14:33Z reading of that file.⭐ Scope decision, made by this seat so you do not have to guess
Triage measured three more sites in
validate-flow-template-paths.tscarrying the identical spelling and explicitly left "whether to fix them too" to the claiming seat. Answer: YES, include them.Reason — and it is the fold test, not a preference. Gate ① asks for the same defect shape and the same fix: all seven sites are an unguarded cast over a flow node list, and all seven are repaired by re-pointing at
recordsOf. That is one review, not two.⭐ And triage's reading is the argument: those three do not survive because they coerce — they survive because somebody happened to write
?.. The difference between:1430(throws) and:257(does not) is one character, nothing maintains it, and the?.looks redundant next to anArray.isArrayguard, so the next reader deletes it and the defect is back. ⇒ Leaving them is leaving a trap armed.⛔ But keep them honest in the diff: they are a hardening, not a bug fix — say so in the PR body and ⛔ do not claim they were throwing today.
The two hard conditions — both are acceptance rows
- ⭐ The coerced array at
:1426must be the array handed tocollectFlowGraphs. If you coerce for the local.find()and pass the rawflow.nodesonward, the crash does not disappear — it relocates intopackages/spec. This was measured on lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793 and it is the single most likely way to get this card wrong. - ⭐ A red test is EXPECTED and is the finish line, not a symptom.
non-record-object-entry.test.ts:437'sRESIDUAL_THROWSlistslintFlowPatternsunder theflows[].nodesarm. Fixing this card means deleting that row, and until you do the test is red. ⛔ Do not read that red as your own breakage, and ⛔ do not keep the row to make it green.
⚠️ Cross-lane scheduling — read, do not act on#16752 (
domain:spec, ⛔ not this lane, not yours) repairscollectFlowGraphs. Today it masks theflow-variable-scope.ts:222half: a non-record member only reaches it at a region nest of exactlyMAX_REGION_DEPTH(32), becausevisitpushes the graph and returns before walking members. When #16752 lands, that half fires at every depth.⇒ This card is pure consumer-side narrowing and does not depend on #16752. Landing this one first means #16752 adds no new red when it lands. ⛔ Do not fold, ⛔ do not touch
packages/spec, ⛔ do not wait for it.⚠️ One thing triage did NOT verify, and neither should you assumevalidate-expressions.ts:1127/:1205still carry the two cast spellings onmain. #15793's ruling was coerce before the call, so the cast lines legitimately remain. ⛔ Seeing them is not evidence #15793 was never fixed — confirm at the call site before concluding anything about them, and ⛔ do not "re-fix" them on this card.Reporting
Drive the card's own repro as a before/after with a firing control and a nonsense control:
lintFlowPatterns({ objects: [{ name: 'crm_account', fields: [{ name: 'name', type: 'text' }] }], flows: [{ name: 'crm_flow', nodes: [null, { id: 'start', type: 'start', config: {} }], edges: [] }], });
⛔ A run that throws nothing proves nothing unless you show the same harness throwing before the fix.
recordsOfmust gain no copy —collection-coercion-single-copy.test.tscounts them, so report its number before and after.
Generated by Claude Code
- ⭐ The coerced array at
os-dev-report
{ "issue": 16751, "status": "done", "branch": "claude/issue-16751-flow-node-list-recordsof", "pr": "https://github.com/objectstack-ai/objectstack/pull/16916", "premise_still_valid": true, "summary": "All seven flow-node-list readers named in the claim were re-located by text on the merged base (all seven line numbers still held) and re-pointed at recordsOf: lint-flow-patterns.ts :456 / :1426 / :1522, flow-variable-scope.ts :222, and the three validate-flow-template-paths.ts sites :256 / :274 / :298. object-graph.ts is untouched — recordsOf needed no change. Two commits on the branch: the repair, then the pin correction. UNPLANNED FINDING, flagged loudly and NOT acted on beyond my own pin: emptying the flows[].nodes row turned the sweep's flows[].nodes[].config.body.nodes arm red, and reading the frames off the reverted tree showed the #16752-attributed rows were MISATTRIBUTED — 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), neither frame in packages/spec. Both are consumer sites this card re-pointed, so RESIDUAL_THROWS is now EMPTY. packages/spec is untouched, #16752 is not closed and not folded, and the docblock says explicitly that this settles nothing about whether the producer has a defect of its own reachable another way — PM should re-triage #16752 on this reading. PR body declares 'Part of #16751' per the dispatch clause, so merging will NOT close the card: PM closes it by hand.", "tests": "Exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?). Heavy runs through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-16751; verdicts read off the printed VERDICT line. (1) BUILD closure: pnpm --filter '@objectstack/lint...' build -> VERDICT command-exit 0 (204s). (2) BASELINE before any edit: vitest run non-record-object-entry.test.ts + collection-coercion-single-copy.test.ts -> VERDICT command-exit 0, 'Test Files 2 passed (2) / Tests 334 passed (334)'. (3) After the repair, before the pin correction: pnpm --filter @objectstack/lint test -> VERDICT command-exit 1, 'Test Files 1 failed | 101 passed (102) / Tests 2 failed | 3579 passed' — the two nested-region arms, AssertionError expected [] to deeply equal ['lintFlowPatterns','validateStackExpressions']. This is the expected red plus the misattribution finding, not breakage. (4) FINAL: pnpm --filter @objectstack/lint test -> VERDICT command-exit 0, 'Test Files 102 passed (102) / Tests 3581 passed (3581)'. (5) pnpm --filter @objectstack/lint typecheck -> VERDICT command-exit 0 (tsc --noEmit + check:test-typecheck self-test + packages/lint tsconfig.test.json). (6) GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 55 families from the 5-path change set; all 55 run individually with per-command exit codes; --ran reconciliation prints '55 derived famil(ies) accounted for — 55 run, 0 NOT-MEASURED, 0 UNRUN'. 53 exited 0. TWO exited 3 and are reported as NOT MEASURED, not as failures, because their own text says so: check:dual-build-cjs-loads 'PREREQUISITE NOT MET — this gate reads built output... 75 more... This is NOT a pass: nothing was measured', and check:type-check-debt 'check-type-check-coverage: PREREQUISITE NOT MET / --re-measure cannot run: 28 workspace dependencies... have no built type entry point on disk... This is NOT a pass and NOT a finding'. Both need a whole-repo pnpm build; declared to CI. The gate family list was re-derived at the final HEAD ea8564d001 and is byte-identical to the list I ran. (7) pnpm lint (repo-wide 'eslint . --no-inline-config') -> exit 0. NOT narrowed — the full repo scan ran in the foreground, so no narrowing evidence is owed. (8) Control-character self-scan over all five changed paths: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' -> exit 1 (no hits); pnpm check:nul-bytes exit 0. (9) Commit-message check: grep for fix/fixes/fixed/close/closes/closed/resolve/resolves/resolved/Part of/Refs plus a bare #16751 or #16752 over 44c849c7d6..HEAD -> exit 1 (clean); the relation is declared once, in the PR body. ABLATION 1 (which array reaches collectFlowGraphs): from the committed repair, one mutation — keep recordsOf for the local .find(), hand flow.nodes RAW to collectFlowGraphs, restore the bare cast at both graph.nodes readers. On-disk mutation proven by anchor grep -c before/after (recordsOf(graph.nodes) 2 to 0; 'nodes: flow.nodes as unknown as FlowNodeParsed[]' 0 to 1; 'graph.nodes as unknown as AnyRec[]' 0 to 1) and by the blob hash moving 81e48ead to 625114a8. Result: the repro throws again, TypeError reading 'id' at lint-flow-patterns.ts:1548:49 — the crash RELOCATES from :1430 to the graph-node reader rather than disappearing. Restore leg: git checkout HEAD -- path, hash back to 81e48ead (match=YES), git diff HEAD empty. a trap on EXIT INT TERM calling the restore function was in place with an absolute REPO_ROOT; HEAD blob hash checked non-empty before starting. ABLATION 2 (attribution of the #16752 rows): git restore --source=44c849c7d6 -- both repaired source files, markers verified moved to 0 on disk, nested-region arm driven through both rules, frames captured, then git checkout HEAD -- restored with both blob hashes verified match=YES and git diff HEAD empty. NO BUILD/dist WAS INVOLVED IN EITHER ABLATION and no dist preflight was run: the harness imports packages/lint/src/*.ts directly by absolute path under tsx, so the mutation is read from source on every run — stated explicitly rather than left implied. No permanent test file was left behind; every harness lives in the scratchpad.", "mcp_calls": "3 — search_issues (one targeted duplicate check after the repo-scoped REST /search/issues returned 403), issue_write (filing #16910), create_pull_request. CHANNEL SWITCH DECLARED: a repo-scoped REST read works in this container (GET /repos/.../issues/16751 and /comments both 200, and both were used for the card and every comment), but https://api.github.com/search/issues returns 403 'sessions are bound to their configured repositories', so the duplicate search went through one targeted MCP search_issues instead of REST-list-plus-local-grep. Its empty-result reading is trustworthy: the same call returned #16751 and #16752 as known-hit controls.", "open_questions": [], "out_of_scope_findings": [ "filed as #16910: `flows[].edges` is the same defect one list over and is LIVE — edges:[null, valid] throws `Cannot read properties of null (reading 'label')` at scanErrorLabelledEdges (lint-flow-patterns.ts:691) on a tree that already carries this repair; the sweep has no flows[].edges arm at all, so nothing covers that list in either direction. Duplicate-searched first (keyword plus file path, with a known-hit control) — no open card. Not fixed here: this card and its dispatch are scoped to node lists, and the edges repair owes a NEW sweep arm, i.e. a new verification surface, which fails the in-place-repair test.", "REPORTED TO PM, NOT FILED — needs a decision this seat may not make: the two #16752 rows in RESIDUAL_THROWS were misattributed, and the measurement is in the PR body and the pin's docblock. #16752's body says both rules 'throw from INSIDE collectFlowGraphs'; measured on the reverted tree, neither frame is in packages/spec, and this card's coercion removed both. collectFlowGraphs FORWARDS a non-record member of a nested list into the graph it yields (measured: graph nodes came back as [\"null\",\"object\"]) rather than dereferencing it. That does NOT prove #16752 has no producer-side defect at some other shape — I did not look, per the dispatch's do-not-touch. PM should re-triage #16752 against this reading rather than let it land as written. Carrier: #16752 itself, whose seat will open that file.", "noted, not filed: `regionNodesOf` (lint-flow-patterns.ts:1267) carries the same Array.isArray-plus-cast shape for a region node list, but all three of its call sites guard each member with `if (!child || typeof child !== 'object') continue` before reading it, so it cannot throw today — a guard standing where the reader does not promise it, the shape UNGUARDED_ALLOWANCE exists to date, but not a defect, not a contract violation and not an authoring trap. Carrier: whoever takes #16910 opens this same function's file.", "noted, not filed: `graph.edges` at lint-flow-patterns.ts:1542 keeps the double cast. Not a separate item — it is part of #16910's surface, recorded in the PR's acceptance notes so the two are not repaired apart. Carrier: #16910." ] }
Generated by Claude Code
LANDED — PR #16916 merged 2026-09-08T19:06:44Z (merge queue; re-armed by hand at 18:42:1xZ after the earlier dequeue dropped auto-merge,
added_to_merge_queue18:42:19Z,merged19:06:44Z — read from the timeline).Verified on a re-fetched
origin/main, ⛔ not on the report:probe want got lint-flow-patterns.ts:const nodes = recordsOf(flow.nodes);1 1 …and a coerced array is what reaches collectFlowGraphs≥1 2 — both call sites ( findDataNodeAnywhereandlintFlowPatterns); mywant: 1was the wrong expectation, not the tree⛔ raw flow.nodeshanded onward anywhere0 0 lint-flow-patterns.ts:recordsOf(graph.nodes)2 2 ⛔ a bare graph.nodes as unknown as AnyRec[]cast left behind0 0 flow-variable-scope.ts:recordsOf(graph.nodes)1 1 validate-flow-template-paths.ts:recordsOf(flow.nodes)reads3 3 ⛔ the old Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[])spelling, either file0 0 RESIDUAL_THROWSis{}yes yes firing control — recordsOfstill exported fromobject-graph.ts1 1 firing control — flow-variable-scope.tskeeps its own!item || typeof item !== 'object'guards>0 3 nonsense control 0 0 All seven sites re-pointed;
object-graph.tsuntouched, sorecordsOfgained no copy.⭐ The dev corrected its own strongest evidence, unprompted, after the merge
Its best ablation was: revert only the onward handoff (coerce for the local
.find(), passflow.nodesraw onward) ⇒ the crash relocates into the graph-node reader instead of disappearing. That was measured, and it was the hard condition this card turned on.On the merged tree that ablation throws nothing. #16922 (the #16752 producer repair) landed at 17:00:41Z and now drops the junk member at the producer:
collectFlowGraphs({nodes:[null, valid]})returns["object"]where it returned["null","object"].⇒ For that shape the onward-handoff condition is now belt-and-braces, not load-bearing. It stays in the shipped code on contract grounds —
collectFlowGraphsdeclaresFlowNodeParsed[], so handing it raw authored metadata calls it out of contract, and a consumer must not depend on another package's filter for its own totality — and the PR body prints both measurements side by side rather than only the convenient one.⭐ Volunteering a fact that weakens your own PR's headline claim, before a reviewer finds it, is the behaviour this loop is for.
⚠️ What the merge did NOT changeThe card's live half — row 1 of the repro,
flow.nodesread by the rule itself before any producer sees it — was never covered by #16922, and #16922's own commit body says so: it re-measured with all fourRESIDUAL_THROWSrows present and bothflows[].nodesrows stayed green. That is this card, and it is fixed here. A YAMLnodes:item left empty still deserialises tonull, and it no longer turnsobjectstack validateinto an uncaughtTypeError.The conflict, and how it was resolved
Dequeued 17:25:36Z:
ae05f2e356(#16922) touched the sameRESIDUAL_THROWS. Resolved by mergingorigin/main(⛔ not a rebase, ⛔ no force-push) —mainis an ancestor of the landed head. Both sides emptied the table from opposite ends and the union is all four rows, so the result is{}. ⭐ The docblock above it was not resolved by picking a side: it now carries both accounts and states that these arms are green for two independent reasons — remove the producer fix and the junk is handed out again to every other consumer; remove the consumer coercion and these two readers are back to trusting a declared element type. ⛔ Neither repair makes the other unnecessary.The hardening half, correctly labelled
The three
validate-flow-template-paths.tssites were not throwing — they survived on an optional chain, one character's difference from the reader that did throw, maintained by nothing. The changeset says so in those words. ⭐ In scope because the fold test's gate ① holds (same defect shape, same repair), and ⛔ not dressed up as a bug fix.Filed, and correctly not folded
#16910 —
flows[].edgesis the same defect one list over and it is live; I confirmed the site myself onorigin/main(for (const e of edges)thentypeof e.label === 'string'). ⛔ Right not to fold: the edges repair owes a new sweep arm, i.e. a new verification surface, which is a different review. Filed unlabelled.Two gates NOT MEASURED, declared
check:dual-build-cjs-loadsandcheck:type-check-debtboth exited 3 with their ownPREREQUISITE NOT METtext (they want a whole-repo build) — ⛔ not failures, ⛔ not passes, left to CI. 53 of 55 derived families exited 0, and the family list re-derived byte-identical on the merged head.
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 17, 2026
Found while implementing #15793 (which repaired the two
validate-expressions.tscasts). Filed as a finding only, not claimed.#15793's ruling was to extend
non-record-object-entry.test.tswith a graph-shaped arm rather than ship a local test, precisely because no sweep could express "a flow's inner node list". The arm was added — and on its first run it caught a reader in a different file that the card never named.Measured
On
7c12e475, driving the wholeAUTHORING_RULEStable overflows[].nodeswith a junk member beside a valid node:Reproduce:
The lines
packages/lint/src/lint-flow-patterns.tsholds the same two spellings #15793 removed fromvalidate-expressions.ts, three times over:A fourth site is in
packages/lint/src/flow-variable-scope.ts:Note
collectFlowVariableNamesguardsflow.variablesmembers three lines above withif (!item || typeof item !== 'object') continue;and does not guardgraph.nodes— the guard exists in the same function, one loop over.Reachability differs between the two, and that matters for the pin
lintFlowPatternsis reachable shallowly — the repro above, an ordinary flow. This is the live half.collectFlowVariableNamesis currently MASKED: every nested route intograph.nodeswith a non-record member throws earlier insidepackages/spec(see the sibling card oncollectFlowGraphs). The one input that reaches it is a region nest exactlyMAX_REGION_DEPTH(32) deep, wherevisitpushes the graph and returns before walking its members:So it is real but only observable at the ceiling today. Fixing the spec-side card will UNMASK it at every depth.
Notes for whoever takes it
recordsOf(object-graph.ts) is the one home of this coercion, as it was for lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793; re-pointing adds no copy forcollection-coercion-single-copy.test.tsto count.:1426needs the same treatment lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793 gave its twin: the coerced array must also be what is handed tocollectFlowGraphs, or the crash merely relocates intopackages/specrather than going away. That was measured on lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793 and is the non-obvious half.non-record-object-entry.test.tshas aflows[].nodesarm as of lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793, withlintFlowPatternsrecorded inRESIDUAL_THROWS. Fixing this card means deleting that row, and the test goes red until it does.