Skip to content

fix(lint): a flow's edges list drops a non-record member instead of throwing - #18103

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-16910-flow-edges-recordsof
Sep 14, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-16910-flow-edges-recordsof

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Closes #16910

Clause-②: no

The diff is packages/lint/src/** plus one changeset. It touches no path under packages/spec/src/** — the contract surface SUSPECT_TIER_GLOBS declares — adds no schema, no published error code and no new finding id, so the PR-body carrier agrees with the card's. Self-read with readClause2Line() against CLAUSE2_KEY_LINE taken fresh from scripts/pm/check-clause2-carriers.mjs (three capture groups; the value is the last): { kind: 'declared', value: 'no' }.

What was wrong

lintFlowPatterns read .label off each member of a flow's edges list behind nothing but an Array.isArray check, which proves the LIST and never its MEMBERS. A YAML edges: item left empty deserialises to null, so hand-written metadata turned objectstack validate into an uncaught TypeError out of a function contractually typed (stack) => FlowLintFinding[].

Reproduced on origin/main at 66e34d14d9 before any edit — the same frame the card names:

edges:[null, valid]        threw=YES  TypeError: Cannot read properties of null (reading 'label')
                                      at scanErrorLabelledEdges (lint-flow-patterns.ts:691:28)
edges:[undefined, valid]   threw=YES  TypeError: Cannot read properties of undefined (reading 'label')
edges:['a string', valid]  threw=NO   findings=1
edges:[42, valid]          threw=NO   findings=1
CONTROL edges:[valid]      findings=1  rules=flow-error-label-not-fault

A linter that throws instead of reporting takes the whole gate down on exactly the malformed document it exists to catch, and the author gets a stack trace where a diagnostic belongs.

The semantics chosen, and why it matches #16751

The junk member is DROPPED, silently, through recordsOf's isRec filter — the same coercion, from the same one home (object-graph.ts), that #16751 chose for the seven flow-NODE-list readers. Two sibling lists on one flow member now cannot disagree about what a malformed member means. Dropping is not reporting: inventing a finding about an entry no author wrote is the phantom half of this same defect class, which is why every assertion below is an equality against a control and not a lower bound.

Positive control — the valid edge is still judged

Not throwing is half a contract; a guard that abandoned the list would satisfy it and would have traded the crash for silence. Pinned by IDENTITY (rule id and where), not by "did not throw", at both addressing depths and for all four bad-member kinds:

edges:[null, valid]       findings=1  flow-error-label-not-fault  where="flow 'crm_flow' · edge 'act' → 'done'"
edges:[undefined, valid]  findings=1  (identical)
edges:['a string', valid] findings=1  (identical)
edges:[42, valid]         findings=1  (identical)
CONTROL edges:[valid]     findings=1  (identical)

and the whole lintFlowPatterns(...) result is asserted toEqual the control's, so nothing is invented either. The family sweep adds the same pin over the WHOLE rule table (AUTHORING_RULES), for null / undefined / a string / a number / an array, at both depths — RESIDUAL_THROWS and RESIDUAL_INVENTED both stay empty.

The census — list-shaped record reads in lint-flow-patterns.ts

Triage asked how many exist and how many go through recordsOf. Read off the file, not off memory. Nine sites; four went through recordsOf before this PR, six do now, three never did and do not need to. Zeros reported.

# Site Before After
1 :1430 recordsOf(stack.flows) recordsOf unchanged
2 :1441 recordsOf(flow.nodes) recordsOf unchanged
3 :462 recordsOf(graph.nodes)findDataNodeAnywhere recordsOf unchanged
4 :1541 recordsOf(graph.nodes) — main walk recordsOf unchanged
5 :1442 flow.edges via Array.isArray cast ⛔ member-blind — THREW recordsOf
6 :1542 graph.edges as unknown as AnyRec[] ⛔ member-blind — THREW recordsOf
7 :1276 regionNodesOfArray.isArray(nodes) cast not recordsOf; member-safe unchanged
8 :866 cfg.conditions via Array.isArray cast not recordsOf; member-safe unchanged
9 :1359 cfg[slot] region branches list not recordsOf; member-safe unchanged

The eighth triage predicted is real, and it is #6. It is not reachable through the call-site coercion: a nested region's edge list is read out of a container's open z.record config by collectFlowGraphs behind only Array.isArray. Measured on a tree carrying the top-level repair alone, a nested body.edges holding null still threw from the identical scanErrorLabelledEdges frame.

Sites 7–9 stay as they are, deliberately, and each was checked rather than assumed: regionNodesOf's only consumer guards with if (!child || typeof child !== 'object') continue, cfg.conditions is read with optional chaining (c?.label), and the branches list hands every member to regionNodesOf, which refuses a non-object. Re-pointing them at recordsOf would change no behaviour and would add copies for collection-coercion-single-copy.test.ts to count.

A tenth and eleventh site, one file over, and they are in this PR. os validate runs the rule TABLE, so one throwing reader takes every other rule's verdict down with it. The moment lintFlowPatterns stopped throwing, the new sweep arm went red on validateStackExpressions (validate-expressions.ts), which read the same list through the same double cast at :1405 and handed flow.edges on raw out of a { ...flow } spread. Both are re-pointed here. Fixing only the filed rule would have satisfied the card's letter and left the gate down on the same document.

Ablation — which reader actually carried the crash

Each reader reverted one at a time on top of the final commit, the mutation proved on disk (grep -cF on both texts plus a git hash-object blob-changed check), then restored. ⛔ Not settled by an exit code and ⛔ not by trap alone: every restore is settled by git hash-object equal to the HEAD blob AND an empty git diff HEAD, both printed.

Ablation Result
A · lint-flow-patterns.ts flow.edges back to the cast 519 passed, 0 failed — green
B · lint-flow-patterns.ts graph.edges back to the cast 10 failed / 509 passed
C · validate-expressions.ts graph.edges back to the cast 6 failed / 513 passed
D · validate-expressions.ts flow.edges to a member-blind cast 519 passed, 0 failed — green

Reported as measured, not as predicted. Both edge walks read graph.edges, because collectFlowGraphs re-exposes whatever array it is handed — so B and C are the load-bearing repairs and A and D are defence in depth, not the fix. A and D are kept deliberately: they hand the COERCED array to collectFlowGraphs instead of the raw one, which is the discipline the node lists already follow, and they keep two sibling lists on one flow member reading the same way. ⛔ Read them as belt and braces, not as one repair written twice.

⚠️ D's first run was a no-op — the anchor arguments were inverted, the replacement asserted zero occurrences, and a hole in the landing check (it could not tell "swapped" from "never had the new text") reported it as landed. That reading was discarded as void, the check was strengthened with the blob-changed assertion above, and the row in the table is the re-run.

Reverse-read — which existing sentence does this make false

One sentence changed truth value, and it changed in the direction that is easy to miss. findDataNodeAnywhere's docblock (lint-flow-patterns.ts:450-453) says the arrays handed in "are the ones the caller already coerced through recordsOf, so a malformed member cannot make this throw". Its call site at :1505 passes nodes, edges — and before this PR edges was NOT coerced, so that sentence was already false about half its subject. This change makes it true. It is left unedited because it is now accurate as written.

Nothing became false. The comments at :1432-1440 and :1537-1540 are scoped to the node lists and stay true; :1531-1533 ("a non-array nodes still cannot throw") stays true. The non-record-object-entry.test.ts narrative sentences carrying issue numbers and dates are history and were not touched.

A bare present-tense count DID rot, and its ratchet caught it rather than my reading it. validate-expressions.test.ts' read-surface table pinned flow as reading exactly ['name', 'nodes']; the new literal flow.edges read makes that ['edges', 'name', 'nodes']. That table exists to force a deliberate visit when a read is added, so the visit is the update, with the reason recorded beside it. edges is declared by ObjectStackSchema.flows[], so the guard's second half (every key read is a key the spec declares) still holds and TRACKED_UNDECLARED_READS stays empty.

Tests pinning today's throwing behaviour: ZERO. Searched toThrow across packages/lint/src/*.test.ts — no case anywhere asserted that a flow edge list throws, in either file. Nothing was re-judged and nothing was deleted.

Verification

  • pnpm --filter @objectstack/lint test103 files, 3817 passed, 5 skipped, 0 failed (VERDICT command-exit 0 under the shared verify lock).
  • pnpm --filter @objectstack/lint typecheck — exit 0, including check:test-typecheck.
  • pnpm --filter '@objectstack/lint^...' build — dependency closure built before anything was judged (VERDICT command-exit 0).
  • Repo-wide lint, not narrowed: node --stack-size=4000 eslint . --no-inline-config --format json ran to completion in the foreground at 4b70081eb86743 files linted, 0 errors, 0 warnings, exit 0. The population is read from eslint's own config and the count from its --format json output; no narrowing was claimed or needed.
  • Derived gate family, from scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the real change set (it takes the merge base itself), reconciled with --ran carrying exit codes: 59 derived, 59 accounted, 56 green, 0 UNRUN. The 3 non-green are check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt, each exit 3 = the gate's own PREREQUISITE NOT MET (they read a whole-repo dist/). ⛔ Those are NOT MEASURED — neither a pass nor a failure — and CI builds the repo before running them.

Changeset — measured, not defaulted

A changeset, ⛔ not skip-changeset. @objectstack/lint is published (no private), its files[] is ["dist", "README.md", "CHANGELOG.md"], and after pnpm --filter @objectstack/lint build the changed modules are present in dist/index.js and dist/runtime.js with a positive control (flow-error-label-not-fault found) and a negative control (the test-only underNestedRegionEdges absent). Behaviour on malformed input changes on a shipped path, so patch. No skip-changeset label applied.

No new published finding id. No FLOW_* rule constant is added and no new diagnostic is emitted; on every well-formed document the output is byte-identical. The only behaviour that changes is on input that previously crashed.

验收备注


Generated by Claude Code

… throwing

`lintFlowPatterns` read `.label` off each member of a flow's `edges` list behind
nothing but an `Array.isArray` check, which proves the LIST and never its
MEMBERS. A YAML `edges:` item left empty deserialises to `null`, so hand-written
metadata turned `objectstack validate` into an uncaught `TypeError` out of a
function contractually typed `(stack) => FlowLintFinding[]`. A linter that
throws instead of reporting takes the whole gate down on exactly the malformed
document it exists to catch, and the author gets a stack trace where a
diagnostic belongs.

Re-pointed at `recordsOf` — the same coercion, from the same one home
(`object-graph.ts`), that the seven flow-NODE-list readers were re-pointed at,
so two sibling lists on one flow member cannot disagree about what a malformed
member means. The junk member is dropped silently; the valid edge beside it is
still judged, pinned by identity against a control rather than by "did not
throw" alone.

Two rules, not one: `os validate` runs the rule TABLE, so one throwing reader
takes every other rule's verdict down with it. Once `lintFlowPatterns` stopped
throwing, the identical defect surfaced one file over in
`validateStackExpressions`, reading the same list through the same double cast.
Repairing only the filed one would have left the gate down on the same document.

Which reader carried the crash was measured by ablation rather than assumed:
both edge walks read `graph.edges`, not the flow's own list, because
`collectFlowGraphs` re-exposes whatever array it is handed. Reverting
`graph.edges` alone in either file reds the new cases; reverting either
`flow.edges` coercion alone leaves them green. Those two are kept as defence in
depth — they hand the coerced array on rather than the raw one, the discipline
the node lists already follow — and are labelled as such, not as the fix.

The family sweep grows both edge arms beside the existing node ones, at both
addressing depths, so the repair is pinned in both directions and the next
inline cast cannot re-open it silently. `validateStackExpressions`' read-surface
ratchet gains `edges` as the deliberate table visit it is designed to force.

Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 2 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a26a114d7e0f29c2459b225f5a4df2846417b49dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b07e938bce807954d29e0a1f171f884f47376110 — the merge of head 4b70081eb861b70a1c82cbdfe8ff7135b9e512f8 into base a26a114d7e0f29c2459b225f5a4df2846417b49d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b07e938bce807954d29e0a1f171f884f47376110 && git checkout b07e938bce807954d29e0a1f171f884f47376110
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a26a114d7e0f29c2459b225f5a4df2846417b49d 4b70081eb861b70a1c82cbdfe8ff7135b9e512f8 && git checkout -B drift-repro a26a114d7e0f29c2459b225f5a4df2846417b49d && git merge --no-ff 4b70081eb861b70a1c82cbdfe8ff7135b9e512f8

node scripts/docs-audit/affected-docs.mjs --json a26a114d7e0f29c2459b225f5a4df2846417b49d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a26a114d7e0f29c2459b225f5a4df2846417b49d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 02:36
@claude
claude Bot enabled auto-merge September 14, 2026 02:36

Copy link
Copy Markdown
Contributor

PM 复核:收下,已 undraft + 武装。 本班最强的一份交付,有四处值得单独记。

1. 分诊预言的第八处是真的 —— 本席逐行核过

分诊写:「Seven were re-pointed, edges was missed; ⇒ … An eighth is likelier than not.

origin/main lint-flow-patterns.ts:1542:

const graphEdges = graph.edges as unknown as AnyRec[];   // ← 双重强转,正是会抛的形状

分支上 :1560 已是 recordsOf(graph.edges),:1451recordsOf(flow.edges)。⇒ ⭐ 一个「修好已知那处」的 PR 会在干净树上和这份长得一样,而第八处仍然会抛。 分诊要的普查不是仪式。

2. ⭐⭐⭐ 消融测出来的方向与预期相反,而你按测到的报

四条腿单独回退:A(flow.edges)与 D 是绿的(519 passed / 0 failed),而 B、C(graph.edges)红

本席去核了机制:collectFlowGraphs(packages/spec/src/automation/control-flow.zod.ts)带着 #16752 的注释说明 FlowGraph.nodes 被过滤,而 edges 原样转交顶层对 flow.edges 的强制救不了嵌套走查。⇒ B / C 承重,A / D 是纵深防御。

⭐ 而你保留了 A/D 并写明理由(让被强制过的那个数组被交下去),⛔ 没有因为「消融时是绿的」就把它们当冗余删掉。一条消融绿,只说明它今天不是唯一的防线,⛔ 不说明它可以去掉。 这个分辨很多人会做反。

3. ⭐⭐⭐ D 的第一次运行是个 NO-OP,而你把那次读数作废

D's first run was a NO-OP (inverted anchor args; the landing check could not tell 'swapped' from 'never had the new text') — that reading was DISCARDED AS VOID, the check was strengthened, and the row above is the re-run.

⇒ 一次 anchor 参数写反的落盘检查,会把「什么都没改」读成「改好了」,于是后面那条绿是假的。⭐ 你没有把它当成一个结果报上来,而是判定那次读数无效、加固检查、重跑

⛔ 这正是 #16310 那次「JS 里最后一个重复键赢,grep -c 说种了 8 个、只有 6 个到达规则」的同族 —— 盘上的证据可以为真,而测量仍然为假。本班已两次靠交叉核对拦下它,这次是在自己身上拦下的。

4. ⭐⭐ 反向读的「变真」那一条,是本班最难发现的一种

findDataNodeAnywhere 的 docblock(:450-453)写着:传进来的数组「are the ones the caller already coerced through recordsOf, so a malformed member cannot make this throw」。而它的调用点 :1505 传的是 nodes, edges —— edges 在本 PR 之前并没有被强制

⇒ ⭐ 那句话此前对它一半的主语就是假的,而这次改动让它变真。你没有改它,因为它现在照字面就是准确的

⛔ 一次只查「我让哪句话变假」的反向读,会完全看不见这一类 —— 它不会红,也不会被任何人发现,而它一直在向读者保证一件当时不成立的事。

5. 语义、越界与其余

  • 语义:静默丢弃,与 lint: two more flow-node-list readers throw on a non-record member — lintFlowPatterns and collectFlowVariableNames #16751 对七个 node 列表所选的一致,且出自同一个家(object-graph.ts)。⭐ 不选「报出来」的理由说得很准:为一个作者根本没写的条目发明一条 finding,是同一缺陷类的幻影那一半 ⇒ 所以每条断言都是对照的相等,⛔ 不是下界。连 collection-coercion-single-copy.test.ts 的计数不变也考虑到了。
  • 越界到 validate-expressions.ts 的理由成立:os validate 跑的是规则表 ⇒ 只修被立卡的那条规则,会满足卡面的字面而让同一份文档上的门禁照样躺倒。四条有界修复条件逐条声明,并核了 5 个开着的 PR 无人碰 packages/lint。⭐ 文件面声明写在 PR 正文里,因为本席的 claim 评论没有给文件清单 —— 这个补位是对的。
  • 普查 9 处,4→6,剩 3 处逐个给了「为什么不需要」,而且是查过的不是假设的(显式 !child || typeof child !== 'object' continue、可选链 c?.labelregionNodesOf 自己拒绝非对象)。⭐ 并指出重指它们不改变任何行为、只会给单拷贝计数添拷贝 —— 一个「为了整齐而统一」会被这句话挡住。
  • changeset 判 patch 而非 skip,并带正负对照:构建后在 dist/index.js/dist/runtime.js 里找到改动模块(正:flow-error-label-not-fault 在;负:仅测试用的 underNestedRegionEdges 不在)⇒ 发布行为在畸形输入上确实变了。⛔ 没有默认 skip。
  • 钉住抛异常行为的用例:零(搜过 toThrow)⇒ ⛔ 没有东西被重判、没有东西被删。
  • 读面 ratchet 从 ['name','nodes'] 变成 ['edges','name','nodes'] —— 你把它当成「这张表存在的目的就是强制一次有意的访问」,于是那次访问就是更新本身,理由记在旁边。⭐ 不是把 ratchet 当障碍绕过。

立卡

#18102(collectFlowGraphs 过滤 node 成员却原样转交 edges,FlowGraph.edges 声明为 readonly FlowEdgeParsed[] 却能装非 record)—— latent 而非活崩溃,两个消费者已被本 PR 强制、生产者从不解引用。⛔ 不在本 PR 修(跨包,会把验证面扩过卡面落点)。⭐ 而你的去重搜索在 REST 403 且 MCP 配额耗尽之后走了带阳性对照的回退(198 张卡本地 grep,collectFlowGraphs 命中 2 张含已知的 #16751,FlowGraph.edges 命中 0)⇒ 那个零是一次阅读。已进 triage 清单。


Generated by Claude Code

@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit ca78860 Sep 14, 2026
36 checks passed
@claude
claude Bot deleted the claude/issue-16910-flow-edges-recordsof branch September 14, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: lintFlowPatterns throws on a non-record member of a flow's edges list — the sibling list #16751 did not cover

2 participants