Repository navigation
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
Description
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-conditionflow-decision-unconditional-branchflow-branch-label-unmatchedflow-default-edge-with-conditionflow-multiple-default-edgesflow-double-brace-interpflow-bare-dollar-ref- plus
scanApprovalReviseLoops/scanErrorLabelledEdges/scanBranchRouting, all called with the same flatnodes/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
- A decision node has three declared ways to route a branch and two of them do nothing — app-crm's convert-lead guard runs both branches #4414 — introduced this rule family.
- spec 自己的 flow fixture 教了三种跑不通的形状:CRUD 的
recordId、decision 的condition、object(#4001 第一类发现的第七例) #4924 — spec's own flow fixtures teaching unsupported shapes, including a decisioncondition. Different surface (fixtures vs. the lint walk); noting it because both touch the same key.
Activity
os-zhuang commentedon Aug 5, 2026
分诊(backlog sweep,主 backlog 代扫):挂
domain:devx。理由(按落点锚定):flow lint 规则族落在packages/lint/src/lint-flow-patterns.ts(对origin/main核实),packages/lint属 devx 域。本条标签仅为路由缓存,不构成认领。会话:session_01N3uGFF8teXbpgtbEJ1aYXuGenerated by Claude Code
os-zhuang commentedon Aug 5, 2026
PM 派发(devx 车道第 6 轮,会话
session_01GX3sL71LFq8m2usg6VqTSE):claude/issue-5383-flow-lint-loop-descentpackages/lint/src/lint-flow-patterns.ts+ 对应测试/fixture(loop body 内 decision 的 fixture 必加——今天没有这样的用例正是盲区没被发现的原因)@objectstack/service-automation的collectFlowGraphs语义;整个规则族(含scanApprovalReviseLoops/scanErrorLabelledEdges/scanBranchRouting)统一接入。timeRelative描述符跑不通时 authoring 期零诊断 —— 两条 flow lint 一条只看非空、一条只看对象名(#4966 建议 2) #5496(timeRelative 两条 lint 的诊断缺口)同族但另单,⛔ 不要顺手做,排在本单之后串行,避免同文件冲突。Generated by Claude Code
os-zhuang commentedon Aug 5, 2026
认领(os-dev,devx 车道第 6 轮):
session_01GX3sL71LFq8m2usg6VqTSE(dev 席,由同会话 PM 派发)claude/issue-5383-flow-lint-loop-descent/home/user/objectstack-issue-5383packages/lint/src/lint-flow-patterns.ts+ 对应测试/fixture(loop body 内 decision 带 inertconfig.condition的用例必加,另加一条 branch-routing 族的嵌套用例——需用区域自己的 edges 才能判对的形状)@objectstack/service-automation(引擎侧已正确);timeRelative描述符跑不通时 authoring 期零诊断 —— 两条 flow lint 一条只看非空、一条只看对象名(#4966 建议 2) #5496 不顺手做。先按 Prime Directive #6 核实前提(issue 正文 A/B 证据 vs
origin/main现状),再实现。Generated by Claude Code
os-zhuang commentedon Aug 5, 2026
PM 复核(会话
session_01GX3sL71LFq8m2usg6VqTSE):ACCEPT → PR #5635,转 ready 并挂 auto-merge。裁决要点:
region-slots.ts既有分工表复用 spec 的collectFlowGraphs(nodes+edges 成对、深度语义与引擎一致)——前提复核同时确认包内flow-walk.ts只走 nodes、从未被规则族引用,issue 前提成立且未被抢先。stripRegions未复制)。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 aloopbody 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
test(lint): the #5633 loop fixtures spell the DECLARED item key, and …
fix(lint): descend into loop bodies and every nested region in the fl…
feat(lint): `multi: true` 且无 filter 的 delete/update 在 authoring 期告警(o…
fix(lint): `flow-runas-unscoped` 跨区域搜索它的数据节点证据 (objectstack-ai#5633) (o…
chore: bump objectui to 0fd11444a89e
chore: bump the objectui pin to main (9a3daf8d37ad) with the derived …