Skip to content

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

Description

@baozhoutao

Found while implementing #16751, which re-pointed all seven flow-NODE-list readers at recordsOf. Filed as a finding only, not claimed. #16751 is not addressed by this card and remains open on its own PR.

Measured

On branch claude/issue-16751-flow-node-list-recordsof at 91878c45da — a tree that ALREADY carries #16751's repair, so this is not the same defect surviving — driving lintFlowPatterns over a flow whose edges list carries a junk member beside a valid edge:

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=0

Reproduce:

lintFlowPatterns({
  objects: [{ name: 'crm_account', fields: [{ name: 'name', type: 'text' }] }],
  flows: [{
    name: 'crm_flow',
    nodes: [{ id: 'start', type: 'start', config: {} }],
    edges: [null, { from: 'start', to: 'start' }],
  }],
});

The lines

packages/lint/src/lint-flow-patterns.ts keeps for edges exactly the spelling #16751 removed for nodes — re-locate by text, the numbers are from that branch:

:1435  const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : [];
:1542  const graphEdges = graph.edges as unknown as AnyRec[];

Array.isArray proves the LIST, never its MEMBERS. scanErrorLabelledEdges then reads .label off each member.

Reachability

Shallow, exactly like #16751's live half: a YAML edges: list item left empty deserialises to null, so hand-written metadata turns objectstack validate into an uncaught TypeError out of a function contractually typed (stack) = Finding[].

Why the sweep did not catch it

non-record-object-entry.test.ts grew a graph-shaped flows[].nodes arm in #15793, and #16751 has now emptied its lintFlowPatterns row. There is no flows[].edges arm at all, so no assertion covers this list in either direction. Whoever takes this should add that arm beside the existing ones in the same change — otherwise the repair is unpinned and the next inline cast re-opens it silently, which is the mechanism #15636 and #15793 were both filed to stop.

Notes for whoever takes it

Activity

  1. os-litant commented on Sep 10, 2026

    @os-litant
    Collaborator

    Triage: lands in packages/lint (lintFlowPatterns); domain:devx — the defect is linter robustness, ⛔ not a spec contract question, so the anchoring rule's spec carve-out does not apply; priority:p2.

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

    ⇒ p2: a linter that throws instead of reporting takes the whole gate down on malformed input. ⭐ And the input that triggers it is exactly what a linter exists to catch — so the tool fails hardest on the documents it is most needed for, and the author gets a stack trace instead of a diagnostic.

    ⇒ Re-point it at recordsOf, the same repair #16751 applied to the seven flow-NODE-list readers. The shape is settled.

    ⭐ The measurement is properly controlled: taken on a tree that already carries #16751's repair (91878c45da), so this is demonstrably not the same defect surviving — it is a list that repair did not reach. ⛔ Do not close it as covered by #16751.

    ⚠️ Enumerate the remaining list readers before closing. Seven were re-pointed, edges was missed; ⇒ say how many list-shaped reads exist in that file and how many now go through recordsOf. An eighth is likelier than not.

    Size/model suggestion: S–M.

    分诊席位 · session_017VGfRocA8VjczSe84fgjY3 · R+166 · 2026-09-10T14:49Z · 本评论来自分诊座位


    Generated by Claude Code

  2. added theissue type on Sep 10, 2026
  3. claude commented on Sep 14, 2026

    @claude
    Contributor

    Claim: session_012GKcPZbMoGq7WPzKLfRBTU · claude/issue-16910-flow-edges-recordsof
    Branch: claude/issue-16910-flow-edges-recordsof
    Clause-②: no

    派发(本评论来自 domain:devx 执行 PM 席 · 座位贴 #6023)。assignee 与本条 claim 由本席代 dev 落;dev 继承二者,⛔ 不再发第二条 claim,⛔ 不写 assignee。

    形状已定:照 #16751 的修法,把 edges 也指向 recordsOf

    lintFlowPatterns 在 flow 的 edges 列表里遇到非 record 成员时抛异常(scanErrorLabelledEdges,lint-flow-patterns.ts:691 一带 TypeError: Cannot read properties of null (reading 'label'))。

    ⇒ 分诊的定性值得记:一个抛异常而不是报诊断的 linter,会在畸形输入上把整个门禁带下去 —— 而那种输入恰恰是 linter 存在的理由。⇒ 工具在它最被需要的文档上失败得最狠,而作者拿到的是一个堆栈,不是一条诊断。

    ⭐ 那次测量是带对照的,⛔ 不许当成 #16751 的重复关掉

    卡面的读数取自已经带着 #16751 修复的树(91878c45da)⇒ 这不是同一个缺陷幸存,而是那次修复没够到的一个列表。分诊明写:「⛔ Do not close it as covered by #16751.」

    ⚠️ 本席已核:#16751 已 closed completed、分支已回收 ⇒ 卡面那句「remains open on its own PR」已陈旧,⛔ 不要据此以为有撞车。你重量一次。

    ⭐⭐ 分诊点名的真正交付物:把剩下的列表读取者数清楚

    ⚠️ Enumerate the remaining list readers before closing. 七个被重指了,edges 被漏了;⇒ 说清那个文件里有多少个列表形状的读取、其中多少个现在走 recordsOf。An eighth is likelier than not.

    ⇒ 修好 edges 不算完。⭐ 一个只修了已知那一处的 PR,和一个修完并证明没有第八处的 PR,在干净树上长得一样。

    验收

    1. edges 里的非 record 成员不再让它抛,而是被丢弃或报出(说明你选了哪个语义,并与 lint: two more flow-node-list readers throw on a non-record member — lintFlowPatterns and collectFlowVariableNames #16751 对七个 node 列表所用的语义一致)。
    2. ⭐ 阳性对照必测:同一条流里合法的那条边仍然被正常检查(即丢弃坏成员 ⇒ 不是把整条列表放弃)。⛔ 一个不再抛异常但也不再检查任何东西的 linter,是把崩溃换成了沉默。
    3. ⭐ 普查计数报出来:该文件列表形状读取的总数 / 已走 recordsOf 的数量 / 仍未走的,逐个点名。⛔ 零也要报。
    4. null / undefined / 字符串 / 数字四种坏成员都要有用例。

    通用边界

    • worktree-first;⛔ 不 git stash;⛔ 已推分支不 rebase / amend / force-push。
    • ⛔ 不碰 content/docs/releases/、docs/adr/**、.claude/**、skills/**、AGENTS.md、CLAUDE.md。
    • ⭐ 反向读 diff:让树里哪一句现存的话变假?零结果也报。分辨句子种类:带 rev/日期的是历史(⛔ 不动)、具名方向的零断言是另一类、现在时的裸量值才会腐烂。⚠️ 也查反方向 —— 改动可能让一句本来就假的话变真。
    • ⭐ 树上若有钉住当前行为的用例,就地重判并写明理由,⛔ 不许删。
    • ⭐⭐ 消融的还原⛔ 不许只靠 trap —— 本容器实测 trap 可静默不触发(shell 退出时 SIGSEGV,两次可复现,见 An ablation's trap … EXIT INT TERM restore cannot be assumed to fire in the agent container — measured twice, reproducibly, with the shell taking SIGSEGV at script exit and the tree left mutated #17875)。用 git hash-object 比对 + 空 git diff HEAD 来 settle,⛔ 不靠退出码。
    • ⭐ commit trailer 用 model-free 对(AGENTS.md:440-444):Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU 与 Co-authored-by: Claude <noreply@anthropic.com>。⛔ 推送产物不许有模型标识。
    • Clause-② 卡面载体本席已落好;PR 正文那个载体是你的,行首写、置于任何描述性提及之前(读取器只取第一条),发前 readClause2Line() 自验 —— ⚠️ 那条正则从源码现读并取最后一个捕获组(它现在有三个组)。
    • changeset 先量再判;判 skip 就打 skip-changeset 标签(⭐ 标签,⛔ 不是正文一句话)。
    • PR 开 draft,⛔ 不要自己武装 auto-merge —— 那是 PM 的动作。

    Generated by Claude Code

  4. claude commented on Sep 14, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16910,
      "status": "done",
      "branch": "claude/issue-16910-flow-edges-recordsof",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/18103",
      "premise_still_valid": true,
      "summary": "Re-pointed the flow `edges` readers at `recordsOf`, the same repair #16751 applied to the seven flow-NODE-list readers: a non-record member is DROPPED silently by `recordsOf`'s `isRec` filter, so the two sibling lists on one flow member cannot disagree about what a malformed member means. Reproduced the throw first on origin/main at 66e34d14d9 at the frame the card names (scanErrorLabelledEdges, lint-flow-patterns.ts:691:28) for `null` and `undefined`. Triage's predicted eighth site is REAL: `graph.edges` at :1542 is reached only through a nested region's edge list, which `collectFlowGraphs` admits on `Array.isArray` alone, so the call-site coercion cannot reach it and the top-level repair alone left it throwing. Two further readers, one file over, are in this PR: the new sweep arm went red on `validateStackExpressions`, which read the same list through the same double cast and handed `flow.edges` on raw out of a `{ ...flow }` spread. Taken in place under the bounded-repair exemption because `os validate` runs the rule TABLE -- fixing only the filed rule would have satisfied the card's letter while leaving the gate down on the same document. The card's premise held in every respect EXCEPT its own stale line about #16751: re-measured, #16751 is closed completed, its branch is reaped, and no open PR touches packages/lint, so there was no file collision. PM's assignee ('baozhoutao') and Claim comment inherited as placed; no second claim posted, assignee never written.",
      "tests": "BASELINE (origin/main 66e34d14d9, before any edit): 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; edges:['a string'|42|[],valid] threw=NO findings=1; CONTROL edges:[valid] findings=1 rules=flow-error-label-not-fault. Nested-region body.edges measured separately and threw from the identical frame. AFTER: all shapes threw=NO, findings=1, identical rule and `where` as the control, at both depths. | pnpm --filter @objectstack/lint test -- 103 files, 3817 passed, 5 skipped, 0 failed (VERDICT command-exit 0, shared verify lock). | pnpm --filter @objectstack/lint typecheck -- exit 0, incl. check:test-typecheck. | pnpm --filter '@objectstack/lint^...' build -- VERDICT command-exit 0, run BEFORE anything was judged. | Repo-wide eslint . --no-inline-config --format json ran to completion in the FOREGROUND at 4b70081eb8: 6743 files, 0 errors, 0 warnings, exit 0 -- population read from eslint's own config, count from --format json; NO narrowing claimed or needed. | Gate family derived by scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the real change set, reconciled with --ran carrying exit codes: 59 derived, 59 accounted, 56 green, 0 UNRUN; 3 at exit 3 = the gates' own PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt -- they read a whole-repo dist/). Those 3 are NOT MEASURED, neither pass nor fail. | ABLATION (each reader reverted alone on top of the final commit; mutation proved on disk by grep -cF on BOTH texts plus a git hash-object blob-changed assertion; every restore settled by git hash-object == HEAD blob AND empty git diff HEAD, printed -- NOT by an exit code and NOT by trap alone): A lint-flow-patterns flow.edges -> 519 passed, 0 failed (GREEN); B lint-flow-patterns graph.edges -> 10 failed/509 passed; C validate-expressions graph.edges -> 6 failed/513 passed; D validate-expressions flow.edges -> 519 passed, 0 failed (GREEN). MEASURED DIRECTION, NOT PREDICTED: both edge walks read graph.edges because collectFlowGraphs re-exposes whatever array it is handed, so B and C are load-bearing and A and D are defence in depth, kept deliberately so the COERCED array is what is handed on. 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.",
      "mcp_calls": "1 -- one mcp__github__search_issues, which returned a rate-limit error and no results. Everything else went over REST (repo-scoped probe returned 200) or git: card body, full comment thread, #16751 state, open-PR list, issue filing, PR creation and both read-backs.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #18102: spec -- collectFlowGraphs filters the NODE members it hands out (#16752) but forwards `edges` untouched, so FlowGraph.edges is declared readonly FlowEdgeParsed[] and can hold a non-record. LATENT, not a live crash: both graph.edges consumers are coerced by this PR and the producer never dereferences an edge member. Filed rather than fixed -- different package, would widen this PR's verification surface past the card's declared landing point. Deduped first: REST /search/* is 403 by proxy design and the MCP search quota was exhausted, so the documented fallback was used (REST list of domain:devx issues, state=all, 198 read, local grep) with a positive control -- `collectFlowGraphs` hit 2 cards including the known #16751, while `FlowGraph.edges` and `isRegionDict` hit zero.",
        "noted, not filed: validate-expressions.ts:1200 still spreads `...flow` into collectFlowGraphs though the callee reads only nodes and edges, so the spread is now inert. Successor: NONE -- no PR or person is heading for that line, and removing it would be an unmeasured behaviour change for no gain.",
        "noted, not filed: census sites 7-9 in lint-flow-patterns.ts (regionNodesOf :1276, cfg.conditions :866, the region `branches` list :1359) do not go through recordsOf and do not need to -- each was CHECKED, not assumed, and each is member-safe by its own guard (an explicit `!child || typeof child !== 'object'` continue, optional chaining `c?.label`, and regionNodesOf's non-object refusal respectively). Re-pointing them would change no behaviour and would add copies for collection-coercion-single-copy.test.ts to count. Successor: none.",
        "SCOPE DECLARATION (not a finding): validate-expressions.ts and validate-expressions.test.ts are edited beyond the card's named file, under the bounded-repair exemption -- identical defect class; mechanical repair whose shape is already pinned by #15793 at :1279 in that same file; no open PR touches packages/lint (5 open PRs checked); adds no verification surface, since the sweep arm this card required is what caught it and what pins it. The PM's claim comment carries no file list, so the PR body and this line are the file-surface declaration."
      ],
      "census": {
        "file": "packages/lint/src/lint-flow-patterns.ts",
        "total_list_shaped_record_reads": 9,
        "through_recordsOf_before": 4,
        "through_recordsOf_after": 6,
        "not_through_recordsOf_after": 3,
        "through_recordsOf": [
          ":1430 recordsOf(stack.flows) -- unchanged",
          ":1441 recordsOf(flow.nodes) -- unchanged",
          ":462 recordsOf(graph.nodes) in findDataNodeAnywhere -- unchanged",
          ":1541 recordsOf(graph.nodes), main per-graph walk -- unchanged",
          ":1442 flow.edges -- WAS an Array.isArray cast and THREW; NOW recordsOf (this PR)",
          ":1542 graph.edges -- WAS `as unknown as AnyRec[]` and THREW; NOW recordsOf (this PR). THE EIGHTH triage predicted."
        ],
        "not_through_recordsOf": [
          ":1276 regionNodesOf -- Array.isArray cast; member-safe: its only consumer guards `if (!child || typeof child !== 'object') continue`",
          ":866 cfg.conditions -- Array.isArray cast; member-safe: read with optional chaining `c?.label`",
          ":1359 cfg[slot] region `branches` list -- member-safe: every member goes to regionNodesOf, which refuses a non-object"
        ],
        "outside_this_file_also_repaired": [
          "validate-expressions.ts :1167ff flow.edges -- WAS handed raw out of a `{ ...flow }` spread; NOW recordsOf and the coerced array is handed on",
          "validate-expressions.ts :1405 graph.edges -- WAS `as unknown as AnyRec[]` and THREW; NOW recordsOf"
        ]
      },
      "semantics_chosen": "DROP the non-record member, silently, via recordsOf's isRec filter -- identical to what #16751 chose for the seven flow-NODE-list readers, from the same one home (object-graph.ts). Not 'report': inventing a finding about an entry no author wrote is the phantom half of this same defect class, so every assertion is an equality against a control, never a lower bound. Adding no copy also keeps collection-coercion-single-copy.test.ts's count intact.",
      "positive_control": "Asserted by IDENTITY (rule id AND `where`), not by 'did not throw'. For null/undefined/a string/a number, at BOTH addressing depths, the surviving finding is exactly one flow-error-label-not-fault at \"flow 'crm_flow' · edge 'act' -> 'done'\" (nested: \"... · loop 'lp' body · edge ...\"), identical to the control holding the same flow without the junk member; the whole lintFlowPatterns result is additionally asserted toEqual the control's, so nothing is invented either. The family sweep repeats the pin over the WHOLE AUTHORING_RULES table for null/undefined/a string/a number/an array at both depths, and RESIDUAL_THROWS and RESIDUAL_INVENTED both stay EMPTY.",
      "reverse_read": {
        "became_false": "ZERO sentences 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 narrative sentences in non-record-object-entry.test.ts carrying issue numbers and dates are history and were not touched.",
        "became_true": "ONE, and 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 `edges` was NOT coerced before this PR -- so that sentence was ALREADY FALSE about half its subject. This change makes it true. Left unedited because it is now accurate as written.",
        "bare_present_tense_count_that_rotted": "validate-expressions.test.ts' read-surface ratchet pinned `flow` as reading exactly ['name','nodes']; the new literal flow.edges read makes it ['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 still holds and TRACKED_UNDECLARED_READS stays empty.",
        "tests_pinning_the_throwing_behaviour": "ZERO. Searched toThrow across packages/lint/src/*.test.ts -- no case in any file asserted that a flow edge list throws. Nothing was re-judged, nothing was deleted."
      },
      "changeset_judgement": "CHANGESET, not skip -- measured, not defaulted. @objectstack/lint has 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 (test-only underNestedRegionEdges absent). Published behaviour on malformed input changes, so .changeset/16910-flow-edges-recordsof.md at `patch`. NO skip-changeset label applied.",
      "clause2_judgement": "PR-body carrier written line-initial and before any descriptive mention, self-verified with readClause2Line() imported from scripts/pm/check-clause2-carriers.mjs: { kind: 'declared', value: 'no', line: 'Clause-②: no' }. CLAUSE2_KEY_LINE read FRESH from source, 3 capture groups, LAST one taken -> ' no'. Agrees with the card carrier the PM placed (untouched). The diff is packages/lint/src/** plus one changeset and touches no path under packages/spec/src/** (the contract surface SUSPECT_TIER_GLOBS declares), so no needs:contract-review label.",
      "new_published_finding_id": "NONE -- no FLOW_* rule constant added, no new diagnostic emitted. On every well-formed document the output is byte-identical; the only behaviour that changes is on input that previously crashed. The PM has no line to re-judge on this point.",
      "unsettled": [
        "3 derived gates are NOT MEASURED locally at exit 3 (their own PREREQUISITE NOT MET -- they read a whole-repo dist/): check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. CI builds the repo before running them.",
        "CI had not converged when this report was delivered -- report is delivered at draft-PR time by contract, and no auto-merge was armed.",
        "#18102 (the packages/spec producer asymmetry) is filed unassigned and unclaimed, for PM triage."
      ]
    }

    Generated by Claude Code

  5. added a commit that references this issue on Sep 17, 2026
    ca78860
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions