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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions