Skip to content

flow lint rules never descend into a loop body — the whole family is blind to nested nodes (8 real inert conditions shipped past flow-inert-node-condition) #5383

Closed

Description

@yinlianghui

Found while implementing objectstack-ai/hotcrm#650 (removing inert decision node config.condition copies from HotCRM). Filed unassigned for triage.

What

The flow rule family in packages/lint reads a flow's node and edge lists flat off the top level and never walks the regions nested inside a loop node's config.body. Every rule in that family is therefore blind to any node authored inside a loop.

@objectstack/lint@17.0.0-rc.2, dist/index.js:6791 (the entry point that feeds every flow rule below it):

for (const flow of asArray38(stack.flows)) {
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
  const edges = Array.isArray(flow.edges) ? flow.edges : [];

No descent into node.config.body.nodes / .edges. Contrast @objectstack/service-automation, whose validateNodeConfigKeys and validateFlowExpressions both iterate collectFlowGraphs(flow) precisely so loop-body regions are covered (and tag findings with a graph.scope prefix). The engine walks the regions; the linter does not.

Measured, in a real app

HotCRM has 17 decision nodes. 8 carried the inert singular config.condition that flow-inert-node-condition exists to catch. pnpm lint reported none of them.

All 8 sit inside a loop body:

campaign_enrollment/loop_leads/check_not_enrolled
contract_renewal/loop_contracts/check_notice_window
contract_renewal/loop_contracts/check_not_reminded
contract_renewal/loop_contracts/check_auto_renewal
contract_renewal/loop_contracts/check_no_open_renewal
opportunity_stagnation/loop_opps/check_not_nudged
forecast_snapshot/loop_owners/has_deals
forecast_snapshot/loop_owners/check_missing

Direct A/B on the same repo: temporarily adding the identical key to a top-level decision node (lead_assignment.check_hot) makes the rule fire immediately —

WARN flow 'lead_assignment' - node 'check_hot' (decision): `config.condition` is set but
nothing reads it - the key is the trigger gate on a `start` node and is ignored on every
other node type, so this predicate never gates anything.
  flow-inert-node-condition

— while the 8 loop-nested instances stay silent. Same key, same node type, same flow file set; the only difference is nesting depth.

This is also, most likely, why those 8 survived: the top-level decision nodes in that repo had already been cleaned up (9 of 17 correctly carry no config, and one even carries a comment citing flow-inert-node-condition), so the gate visibly worked where it could see and silently did nothing where it could not.

Scope — it is not one rule

Everything downstream of that binding shares the blind spot. From the same region of dist/index.js:

  • flow-inert-node-condition
  • flow-decision-unconditional-branch
  • flow-branch-label-unmatched
  • flow-default-edge-with-condition
  • flow-multiple-default-edges
  • flow-double-brace-interp
  • flow-bare-dollar-ref
  • plus scanApprovalReviseLoops / scanErrorLabelledEdges / scanBranchRouting, all called with the same flat nodes / edges

Loop bodies are where a lot of real branching lives — a per-item gate inside a sweep is the standard shape for scheduled flows — so this is a large share of authorable flow metadata that no flow rule inspects.

Worth noting the severity asymmetry: flow-default-edge-with-condition is severity: 'error', i.e. a build-stopping rule that currently cannot see a contradiction authored one level down.

Suggested direction

Feed the flow rules from the same region walk the engine uses (collectFlowGraphs, or the linter's own equivalent) and prefix findings with the region scope so the message still points at a unique node — the engine already formats these as scope: node 'id'. Rules that reason about a node together with its out-edges (the branch-routing family) need the region's own edges array, not the top-level one, so the walk has to keep nodes and edges paired per region rather than flattening both into one bag.

A fixture with a decision inside a loop body would pin it; there appears to be no such case today, which is consistent with the gap going unnoticed.

Related

Activity

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    分诊(backlog sweep,主 backlog 代扫):挂 domain:devx。理由(按落点锚定):flow lint 规则族落在 packages/lint/src/lint-flow-patterns.ts(对 origin/main 核实),packages/lint 属 devx 域。本条标签仅为路由缓存,不构成认领。会话:session_01N3uGFF8teXbpgtbEJ1aYXu


    Generated by Claude Code

  2. self-assigned this
    on Aug 5, 2026
  3. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    PM 派发(devx 车道第 6 轮,会话 session_01GX3sL71LFq8m2usg6VqTSE):

    • dev 分支:claude/issue-5383-flow-lint-loop-descent
    • 文件面:packages/lint/src/lint-flow-patterns.ts + 对应测试/fixture(loop body 内 decision 的 fixture 必加——今天没有这样的用例正是盲区没被发现的原因)
    • 要点:区域遍历必须保持每区域 nodes/edges 配对(branch-routing 族要用区域自己的 edges,不能拍平成一个袋子);报告信息加 region scope 前缀,参照 @objectstack/service-automation 的 collectFlowGraphs 语义;整个规则族(含 scanApprovalReviseLoops/scanErrorLabelledEdges/scanBranchRouting)统一接入。
    • 相邻单:timeRelative 描述符跑不通时 authoring 期零诊断 —— 两条 flow lint 一条只看非空、一条只看对象名(#4966 建议 2) #5496(timeRelative 两条 lint 的诊断缺口)同族但另单,⛔ 不要顺手做,排在本单之后串行,避免同文件冲突。

    Generated by Claude Code

  4. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    认领(os-dev,devx 车道第 6 轮):

    先按 Prime Directive #6 核实前提(issue 正文 A/B 证据 vs origin/main 现状),再实现。


    Generated by Claude Code

  5. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    PM 复核(会话 session_01GX3sL71LFq8m2usg6VqTSE):ACCEPT → PR #5635,转 ready 并挂 auto-merge。

    裁决要点:

    1. 没有造第四份遍历:按 region-slots.ts 既有分工表复用 spec 的 collectFlowGraphs(nodes+edges 成对、深度语义与引擎一致)——前提复核同时确认包内 flow-walk.ts 只走 nodes、从未被规则族引用,issue 前提成立且未被抢先。
    2. 两个承重点都有测试钉住:反拍平对照(不同区域同名节点合并出边的凭空 fan-out 误报)与 double-brace 重新归属([lint/automation] 嵌套 region(try_catch / loop / parallel)里的节点对 flow 规则不可见 —— 两条 gating error 静默失效,第三条降级为 warning #4380 同类错误归属,计数不变、归属修正,复用 stripRegions 未复制)。
    3. 先红后绿方向事前声明:9 条新测试 7 红 2 绿与预测一致;红在计数(6 条纯盲区)与红在归属(1 条)分开如实记录,2 条常绿用例价值说明清楚。
    4. 覆盖面验证三层:lint 全包 1293 绿、消费半径(metadata-protocol 388 / cli 807)、真实 app 34 条 flow A/B 全 0 + 注入探针证明 0 非未走到——Dogfood 风险面处置完整。
    5. 边界:flow-runas-unscoped(severity error,会挡构建)的同族盲区不放宽,另立 flow-runas-unscoped (severity error) still searches only top-level nodes — a scheduled flow whose data ops all live in a loop body passes the build and is refused at run time #5633 附实测 A/B 与 Blocked-by: #5383,交分诊——gating 规则的爆炸半径决策不顺手做,正确。timeRelative 描述符跑不通时 authoring 期零诊断 —— 两条 flow lint 一条只看非空、一条只看对象名(#4966 建议 2) #5496 未触碰。

    必需检查报告时刻在跑,auto-merge 以其全绿为前提;docs-drift 三篇为建议性提示,本改动语义只加不改,不需重审。#5633 落地后由分诊定级,与 #5496 同为本车道后续候选。


    Generated by Claude Code

  6. added a commit that references this issue on Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions