Skip to content

Commit d4edb5d

Browse files
os-zhuangclaude
andauthored
fix(lint): descend into loop bodies and every nested region in the flow rule family (#5383) (#5635)
The flow anti-pattern rules read a flow's `nodes` / `edges` flat off the top level, so every rule in the family was blind to anything authored inside an ADR-0031 container — a `loop` body, a `parallel` branch, a `try_catch` try/catch. Loop bodies are where a lot of real branching lives, so this was a large share of authorable flow metadata that no flow rule inspected. Measured in a real app: 8 `decision` nodes carried the inert singular `config.condition` that `flow-inert-node-condition` exists to catch, all 8 inside a `loop` body, and none were reported. The identical key on a top-level decision fired immediately — same key, same node type, only the nesting depth differed. `lintFlowPatterns` now iterates `collectFlowGraphs` — the same traversal the engine's registration pass uses, and the one `validate-expressions.ts` already uses on the author side — and prefixes each finding's `where` with the region scope, so a message still points at exactly one node. Findings on a flow's own graph are unchanged byte for byte, since the top-level graph's scope is empty. Two properties of the walk are load-bearing rather than incidental: - Nodes and edges stay PAIRED per region. The branch-routing rules reason about a node together with its out-edges, and a region is a self-contained sub-graph. Flattening into one node bag plus one edge bag would break them both ways: a nested decision's out-edges would be absent from the top-level list so it would read as having none and be skipped, while two nodes in different regions sharing an id (ids are unique per graph, not per flow) would have their out-edges merged into one phantom fan-out. A regression test pins the second case. - A container's config is read region-STRIPPED for the recursive scans. `collectTemplateStrings` walks config to its string leaves and a container's config physically contains its descendants', so a nested double-brace hit was already visible before this change — but attributed to the enclosing `loop`, the same failure mode #4380 fixed for `validate-flow-template-paths`. Descending without stripping would have made it a double report. It now names the node carrying the string, still exactly once. `stripRegions` is exported from `flow-walk.ts` rather than copied, so there is one definition of that view. `flow-runas-unscoped` deliberately keeps its top-level-only data-node search: widening a build-gating rule is its own change with its own blast radius, filed as #5633. Verified the repo's own example apps (`app-showcase` / `app-crm` / `app-todo`, 34 flows) report zero findings before and after, and that the descent does reach their real loop bodies — an inert condition injected into showcase's `loop_tasks` body is caught and scoped to it. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4658e57 commit d4edb5d

4 files changed

Lines changed: 473 additions & 72 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the flow rule family now descends into `loop` bodies and every other nested region (#5383)
6+
7+
The flow anti-pattern rules read a flow's `nodes` / `edges` **flat off the top
8+
level**, so every rule in the family was blind to anything authored inside an
9+
ADR-0031 container — a `loop` body, a `parallel` branch, a `try_catch`
10+
try/catch. Loop bodies are where a lot of real branching lives (a per-item gate
11+
inside a sweep is the standard shape for a scheduled flow), so this was a large
12+
share of authorable flow metadata that no flow rule inspected.
13+
14+
Measured in a real app: 8 `decision` nodes carried the inert singular
15+
`config.condition` that `flow-inert-node-condition` exists to catch, all 8
16+
inside a `loop` body, and `pnpm lint` reported none of them. The identical key
17+
on a **top-level** decision in the same repo fired immediately — same key, same
18+
node type, only the nesting depth differed. The blind spot also explains its own
19+
survival: the gate visibly worked where it could see, so the top-level copies
20+
got cleaned up while the nested ones read as approved.
21+
22+
Rules now reported at every depth: `flow-inert-node-condition`,
23+
`flow-decision-unconditional-branch`, `flow-branch-label-unmatched`,
24+
`flow-default-edge-with-condition`, `flow-multiple-default-edges`,
25+
`flow-double-brace-interpolation`, `flow-bare-dollar-reference`,
26+
`flow-date-equality-filter`, `flow-phantom-aggregation`,
27+
`flow-error-label-not-fault`, and the `flow-approval-revise-*` family. Note the
28+
severity asymmetry this closes: `flow-default-edge-with-condition` is a
29+
build-stopping `error` that until now could not see a contradiction authored one
30+
level down.
31+
32+
A finding inside a region carries the region scope in its `where`, so the
33+
message still points at exactly one node — `flow 'x' · loop 'sweep' body ·
34+
node 'y' (decision)`, matching the scope vocabulary the engine's registration
35+
pass already uses. Findings on a flow's own graph are unchanged, byte for byte.
36+
37+
Two details worth knowing if you consume these findings:
38+
39+
- Each region is scanned against **its own** `edges`. The branch-routing rules
40+
reason about a node together with its out-edges, and a region is a
41+
self-contained sub-graph, so a nested decision's out-edges live in the
42+
region's own edge list.
43+
- `flow-double-brace-interpolation` / `flow-bare-dollar-reference` scan a node's
44+
config recursively, and a container's config physically contains its
45+
descendants'. A nested hit was therefore already *visible* before this change
46+
— but attributed to the enclosing `loop` rather than the node carrying the
47+
string. Such a finding now names the right node, and is still reported exactly
48+
once.
49+
50+
`flow-runas-unscoped` deliberately keeps looking at top-level nodes only:
51+
widening a build-gating rule is its own change with its own blast radius, and is
52+
tracked separately.

packages/lint/src/flow-walk.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,18 @@ export function flowNodeLabel(node: AnyRec, index: number): string {
118118
return strName(node.label) ?? strName(node.id) ?? `#${index}`;
119119
}
120120

121-
/** `config` minus the region slots, or `undefined` when there is no config. */
122-
function stripRegions(config: unknown): AnyRec | undefined {
121+
/**
122+
* `config` minus the region slots, or `undefined` when there is no config.
123+
*
124+
* Copy-on-write: a config with no region key comes back by reference.
125+
*
126+
* Exported since #5383 because {@link WalkedFlowNode.localConfig} is not the only
127+
* consumer that needs this view. `lint-flow-patterns.ts` walks graphs rather than
128+
* nodes (it needs each region's `edges` too, which this walk does not carry), but
129+
* its recursive config scans hit the identical double-count trap described above —
130+
* so it reads the same region-stripped view, from this one definition.
131+
*/
132+
export function stripRegions(config: unknown): AnyRec | undefined {
123133
if (!isRec(config)) return undefined;
124134
let out: AnyRec | undefined;
125135
for (const key of Object.keys(config)) {

packages/lint/src/lint-flow-patterns.test.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,3 +742,256 @@ describe('flow-inert-node-condition (#4414)', () => {
742742
expect(lintFlowPatterns(conditionNodeFlow('acme_custom_step', { condition: 'a == b' }))).toHaveLength(0);
743743
});
744744
});
745+
746+
/**
747+
* #5383 — the rule family used to read `flow.nodes` / `flow.edges` FLAT, so every
748+
* rule in it was blind to anything authored inside an ADR-0031 container.
749+
*
750+
* Measured in a real app (HotCRM): 8 `decision` nodes carried the inert singular
751+
* `config.condition` that `flow-inert-node-condition` exists to catch, all 8
752+
* inside a `loop` body, and `pnpm lint` reported none. The identical key on a
753+
* TOP-LEVEL decision in the same repo fired immediately — same key, same node
754+
* type, only the nesting depth differed. There was no loop-body fixture anywhere
755+
* in this file, which is consistent with the gap going unnoticed for that long.
756+
*
757+
* Every case below pins the nested finding against its top-level twin, so a
758+
* future flattening of the walk fails here instead of going quiet again.
759+
*/
760+
761+
/** A scheduled sweep: `loop` over leads, with `body` holding the per-item graph. */
762+
function loopBodyFlow(body: { nodes: unknown[]; edges: unknown[] }) {
763+
return {
764+
flows: [{
765+
name: 'campaign_enrollment',
766+
runAs: 'system',
767+
nodes: [
768+
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
769+
{
770+
id: 'loop_leads', type: 'loop', label: 'Loop Leads',
771+
config: { collection: '{vars.leads}', itemVar: 'lead', body },
772+
},
773+
{ id: 'end', type: 'end' },
774+
],
775+
edges: [
776+
{ id: 'e1', source: 'start', target: 'loop_leads' },
777+
{ id: 'e2', source: 'loop_leads', target: 'end' },
778+
],
779+
}],
780+
};
781+
}
782+
783+
describe('#5383 — flow-inert-node-condition descends into a loop body', () => {
784+
// The shipped shape, reduced: a per-item gate inside a sweep, whose predicate
785+
// was written on the node instead of its out-edges.
786+
const nested = () => loopBodyFlow({
787+
nodes: [
788+
{ id: 'check_not_enrolled', type: 'decision', config: { condition: 'lead.enrolled == false' } },
789+
{ id: 'enroll', type: 'create_record', config: { objectName: 'campaign_member' } },
790+
],
791+
edges: [{ id: 'b1', source: 'check_not_enrolled', target: 'enroll' }],
792+
});
793+
794+
it('flags it, scoped to the region so the message still names exactly one node', () => {
795+
const fnds = lintFlowPatterns(nested());
796+
// Exactly one finding overall: no collateral from the descent, and no second
797+
// copy reported against the enclosing `loop`.
798+
expect(fnds).toHaveLength(1);
799+
expect(fnds[0].rule).toBe(FLOW_INERT_NODE_CONDITION);
800+
expect(fnds[0].where).toBe(
801+
"flow 'campaign_enrollment' · loop 'loop_leads' body · node 'check_not_enrolled' (decision)",
802+
);
803+
expect(fnds[0].message).toContain('nothing reads it');
804+
// The decision-specific hint still applies one level down.
805+
expect(fnds[0].hint).toContain('isDefault');
806+
expect(fnds[0].severity).toBeUndefined();
807+
});
808+
809+
it('still reports the TOP-LEVEL twin with no region breadcrumb (the A/B)', () => {
810+
const fnds = lintFlowPatterns({
811+
flows: [{
812+
name: 'campaign_enrollment',
813+
runAs: 'system',
814+
nodes: [
815+
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
816+
{ id: 'check_not_enrolled', type: 'decision', config: { condition: 'lead.enrolled == false' } },
817+
],
818+
edges: [{ id: 'e1', source: 'start', target: 'check_not_enrolled' }],
819+
}],
820+
});
821+
expect(fnds).toHaveLength(1);
822+
expect(fnds[0].where).toBe("flow 'campaign_enrollment' · node 'check_not_enrolled' (decision)");
823+
expect(fnds[0].where).not.toContain('loop');
824+
});
825+
826+
it('descends a loop nested inside a loop — same depth semantics as the engine', () => {
827+
const fnds = lintFlowPatterns(loopBodyFlow({
828+
nodes: [{
829+
id: 'loop_touchpoints', type: 'loop',
830+
config: {
831+
collection: '{lead.touchpoints}', itemVar: 'tp',
832+
body: {
833+
nodes: [{ id: 'check_recent', type: 'decision', config: { condition: 'tp.age_days < 7' } }],
834+
edges: [],
835+
},
836+
},
837+
}],
838+
edges: [],
839+
})).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION);
840+
expect(fnds).toHaveLength(1);
841+
expect(fnds[0].where).toBe(
842+
"flow 'campaign_enrollment' · loop 'loop_leads' body → loop 'loop_touchpoints' body · " +
843+
"node 'check_recent' (decision)",
844+
);
845+
});
846+
847+
it('descends a parallel branch too — the scope names the branch index', () => {
848+
const fnds = lintFlowPatterns({
849+
flows: [{
850+
name: 'fan_out',
851+
runAs: 'system',
852+
nodes: [
853+
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
854+
{
855+
id: 'fan', type: 'parallel',
856+
config: {
857+
branches: [
858+
{ name: 'owner', nodes: [{ id: 'gate', type: 'decision', config: { condition: 'a == b' } }], edges: [] },
859+
{ name: 'watchers', nodes: [{ id: 'ping', type: 'notify', config: { title: 'Hi {record.name}' } }], edges: [] },
860+
],
861+
},
862+
},
863+
],
864+
edges: [{ id: 'e1', source: 'start', target: 'fan' }],
865+
}],
866+
}).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION);
867+
expect(fnds).toHaveLength(1);
868+
expect(fnds[0].where).toBe("flow 'fan_out' · parallel 'fan' branch 0 · node 'gate' (decision)");
869+
});
870+
});
871+
872+
/**
873+
* #5383 — the branch-routing family reasons about a node together with its
874+
* OUT-EDGES, so the walk has to hand each region its own `edges` array. These
875+
* cases are unreachable from the top-level edge list by construction: nothing at
876+
* the top level has `gate` or `push` as a source, so a walk that descended into
877+
* region NODES while still reading top-level EDGES would see zero out-edges and
878+
* skip every one of them.
879+
*/
880+
describe('#5383 — the branch-routing family reads the region’s own edges', () => {
881+
it('flags a nested edge that is both the default and conditional (GATING)', () => {
882+
const fnds = lintFlowPatterns(loopBodyFlow({
883+
nodes: [
884+
{ id: 'gate', type: 'decision' },
885+
{ id: 'nudge', type: 'notify', config: { title: 'Nudge {lead.name}' } },
886+
{ id: 'skip', type: 'end' },
887+
],
888+
edges: [
889+
{ id: 'b1', source: 'gate', target: 'nudge', condition: 'lead.score > 50' },
890+
{ id: 'b2', source: 'gate', target: 'skip', isDefault: true, condition: 'lead.score <= 50' },
891+
],
892+
}));
893+
expect(fnds).toHaveLength(1);
894+
expect(fnds[0].rule).toBe(FLOW_DEFAULT_EDGE_WITH_CONDITION);
895+
// The severity asymmetry the issue called out: a build-stopping rule that
896+
// could not see a contradiction authored one level down.
897+
expect(fnds[0].severity).toBe('error');
898+
expect(fnds[0].where).toBe(
899+
"flow 'campaign_enrollment' · loop 'loop_leads' body · edge 'gate' → 'skip'",
900+
);
901+
expect(fnds[0].message).toContain('contradictory');
902+
});
903+
904+
it('flags a nested unconditional out-edge alongside a guarded sibling', () => {
905+
const fnds = lintFlowPatterns(loopBodyFlow({
906+
nodes: [
907+
{ id: 'gate', type: 'decision' },
908+
{ id: 'nudge', type: 'notify', config: { title: 'Nudge {lead.name}' } },
909+
{ id: 'log', type: 'create_record', config: { objectName: 'touch_log' } },
910+
],
911+
edges: [
912+
{ id: 'b1', source: 'gate', target: 'nudge', condition: 'lead.score > 50' },
913+
{ id: 'b2', source: 'gate', target: 'log' },
914+
],
915+
})).filter((f) => f.rule === FLOW_DECISION_UNCONDITIONAL_BRANCH);
916+
expect(fnds).toHaveLength(1);
917+
expect(fnds[0].where).toBe("flow 'campaign_enrollment' · loop 'loop_leads' body · decision 'gate'");
918+
expect(fnds[0].message).toContain("'log'");
919+
});
920+
921+
it('flags a nested error-labelled edge left at the default type', () => {
922+
const fnds = lintFlowPatterns(loopBodyFlow({
923+
nodes: [
924+
{ id: 'push', type: 'http', config: { url: 'https://example.test/hook' } },
925+
{ id: 'handle', type: 'create_record', config: { objectName: 'sync_error' } },
926+
],
927+
edges: [{ id: 'b1', source: 'push', target: 'handle', label: 'error' }],
928+
})).filter((f) => f.rule === FLOW_ERROR_LABEL_NOT_FAULT);
929+
expect(fnds).toHaveLength(1);
930+
expect(fnds[0].where).toBe(
931+
"flow 'campaign_enrollment' · loop 'loop_leads' body · edge 'push' → 'handle'",
932+
);
933+
});
934+
935+
it('does NOT merge two regions into one bag — a shared node id is not a fan-out', () => {
936+
// `gate` exists in BOTH branches, each with exactly ONE default out-edge.
937+
// Node ids are unique per graph, not per flow, so flattening every region
938+
// into one node bag + one edge bag would see two `isDefault` edges out of
939+
// "gate" and raise flow-multiple-default-edges — a finding neither region
940+
// contains. Pairing each region with its own edges is what keeps this quiet.
941+
const branch = (cond: string) => ({
942+
nodes: [
943+
{ id: 'gate', type: 'decision' },
944+
{ id: 'x', type: 'end' },
945+
{ id: 'y', type: 'end' },
946+
],
947+
edges: [
948+
{ id: 'g1', source: 'gate', target: 'x', condition: cond },
949+
{ id: 'g2', source: 'gate', target: 'y', isDefault: true },
950+
],
951+
});
952+
const fnds = lintFlowPatterns({
953+
flows: [{
954+
name: 'twin_regions',
955+
runAs: 'system',
956+
nodes: [
957+
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
958+
{
959+
id: 'fan', type: 'parallel',
960+
config: {
961+
branches: [
962+
{ name: 'a', ...branch('lead.score > 50') },
963+
{ name: 'b', ...branch('lead.score > 90') },
964+
],
965+
},
966+
},
967+
],
968+
edges: [{ id: 'e1', source: 'start', target: 'fan' }],
969+
}],
970+
});
971+
expect(fnds.filter((f) => f.rule === FLOW_MULTIPLE_DEFAULT_EDGES)).toHaveLength(0);
972+
expect(fnds).toHaveLength(0);
973+
});
974+
});
975+
976+
describe('#5383 — a recursive config scan does not double-report the container', () => {
977+
it('moves a nested double-brace finding onto the node carrying it, still exactly once', () => {
978+
const fnds = lintFlowPatterns(loopBodyFlow({
979+
nodes: [{ id: 'send_reminder', type: 'notify', config: { title: 'Reminder: {{lead.name}}' } }],
980+
edges: [],
981+
}));
982+
// Exactly one. The `loop`'s own config physically CONTAINS `body`, and
983+
// `collectTemplateStrings` is recursive, so descending without stripping the
984+
// region slots would report this a SECOND time against 'loop_leads'.
985+
expect(fnds).toHaveLength(1);
986+
expect(fnds[0].rule).toBe(FLOW_DOUBLE_BRACE_INTERP);
987+
expect(fnds[0].where).toBe(
988+
"flow 'campaign_enrollment' · loop 'loop_leads' body · node 'send_reminder' (notify)",
989+
);
990+
// Before #5383 the COUNT was already 1 here — the string was found by
991+
// recursing through the container's config and attributed to the `loop`.
992+
// That is the `validate-flow-template-paths` failure mode (#4380): visible,
993+
// but judged against a node that does not carry the string. So for this rule
994+
// the fix is re-attribution, not new visibility.
995+
expect(fnds[0].where).not.toContain("node 'loop_leads'");
996+
});
997+
});

0 commit comments

Comments
 (0)