Skip to content

Commit 5293114

Browse files
os-zhuangclaude
andauthored
fix(automation): enforce isDefault and stop swallowing an unclaimable branch label (#4440)
* fix(automation): enforce isDefault and stop swallowing an unclaimable branch label (#4414) A `decision` node advertised three ways to split a path and only `edge.condition` did anything. `FlowEdgeSchema.isDefault` had zero readers outside its own declaration, and the `conditions[].label` → `branchLabel` route matched 0 out-edge labels across every example app before falling back to the full edge set in silence. Stacked, the two shipped a guard that does not guard: `crm_convert_lead_wizard` showed an already-converted lead the abort screen AND walked it into the conversion wizard behind it. The three mechanisms now compose as one model in `traverseNext`: `branchLabel` narrows the edge set, `condition` gates each edge, `isDefault` catches whatever is left. - `isDefault` is enforced as the BPMN default flow: traversed only when no conditional sibling matched, and kept out of the unconditional parallel fan-out. Passed over because a real branch won, its target records the same `skipped` step a closed gate does (#4354). - A branch label no out-edge carries is logged instead of swallowed. Traversal still falls back to the full edge set — a run mid-flight must not die on a metadata error — but says which branch was computed and which labels exist. - A decision that declares no `conditions` reports no branch. It used to report `'default'` regardless, a label no out-edge in the repo carried, which is why every decision node fell back to the full edge set. The sentinel survives for the case it describes (declared conditions, none matched) and is now claimed by the `isDefault` edge as well. - `conditions[].expression` is evaluated as the bare CEL it is declared to be. The raw string went to the legacy `{var}` template path, where a dotted reference cannot resolve and the branch is decided by string comparison; a brace-in-CEL predicate now fails loudly (ADR-0032 §1c). Caught at authoring time too, since a wrong route is silent at run time by nature (Prime Directive #12): `flow-branch-label-unmatched`, `flow-decision-unconditional-branch`, `flow-default-edge-with-condition` and `flow-multiple-default-edges`. The first two fire on the pre-fix `convert-lead.flow.ts` and are silent after it. app-crm's guard is now a plain exclusive gateway — the redundant `config.conditions` is gone and `e3b` carries `isDefault: true`, so exactly one branch runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 * docs(automation): name the #4414 shape in the branching section Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 * docs(spec): regenerate the flow/decision reference pages after the isDefault contract change `content/docs/references/` is generated from `packages/spec`; the #4414 `.describe()` rewrites left flow.mdx and schemaless-node-config.mdx stale, which is what `check:docs` caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad5fe25 commit 5293114

12 files changed

Lines changed: 901 additions & 38 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/cli": minor
5+
"@objectstack/example-crm": patch
6+
---
7+
8+
fix(automation): a decision's three declared ways to route a branch are now one working model (#4414)
9+
10+
A `decision` node advertised three mechanisms for splitting a path and only one
11+
of them did anything. The other two were the ADR-0049 `declared ≠ enforced`
12+
shape, and the pair of them shipped a guard that does not guard in
13+
`examples/app-crm`.
14+
15+
| mechanism | before | now |
16+
|:---|:---|:---|
17+
| `edge.condition` | ✅ the only one that worked | unchanged |
18+
| `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` |
19+
| `decision.config.conditions[].label``branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed |
20+
21+
## What was broken, end to end
22+
23+
`crm_convert_lead_wizard` means "already converted → abort screen; otherwise →
24+
the wizard". It ran **both**: an already-converted lead got
25+
"This lead has already been converted" and then walked straight into the
26+
conversion wizard behind it. Four independent silences stacked up:
27+
28+
1. the decision's first condition was authored `{lead_record.status} ==
29+
'converted'` — braces in a slot declared bare CEL, so it was string-compared
30+
and never true;
31+
2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`;
32+
3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal
33+
discarded the branch and considered every out-edge;
34+
4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking
35+
it `isDefault: true`, was a dead key.
36+
37+
## The model
38+
39+
`branchLabel` narrows the edge set → `condition` gates each edge → `isDefault`
40+
catches whatever is left. Concretely:
41+
42+
- **`isDefault` is enforced.** A default edge is traversed only when no
43+
conditional sibling of the same source node matched, and it is no longer part
44+
of the unconditional parallel fan-out — that distinction is the whole point of
45+
the marker. Passed over because a real branch won, its target records the same
46+
`skipped` step a closed gate does (#4354).
47+
- **An unclaimable branch label warns.** Traversal still falls back to the full
48+
edge set (a run mid-flight must not die on a metadata error) but says so,
49+
naming the computed branch and the out-edge labels that exist.
50+
- **A decision that declares no `conditions` reports no branch.** It used to
51+
report `'default'` unconditionally — a label no out-edge in the repo ever
52+
carried — which is why every decision node fell back to the full edge set.
53+
The `'default'` sentinel survives for the case it actually describes (declared
54+
conditions, none matched) and is now claimed by the `isDefault` edge as well
55+
as by an edge literally labelled `'default'`.
56+
- **`conditions[].expression` is evaluated as the bare CEL it is declared to
57+
be.** The raw string went to the legacy `{var}` template path, where
58+
`lead.status == 'converted'` cannot resolve and the branch is decided by
59+
string comparison. Unlike `edge.condition` this slot carries no
60+
`ExpressionInput` envelope — the decision descriptor is deliberately
61+
schemaless — so the executor supplies the dialect. A brace-in-CEL predicate
62+
now fails loudly (ADR-0032 §1c) instead of deciding `false`.
63+
64+
## Caught at authoring time too
65+
66+
Four new `os build` / `os validate` warnings, because a wrong route is silent at
67+
run time by nature (Prime Directive #12):
68+
69+
`flow-branch-label-unmatched` (the shipped shape),
70+
`flow-decision-unconditional-branch` (a guarded decision with an unconditional
71+
sibling — the actual hole), `flow-default-edge-with-condition` and
72+
`flow-multiple-default-edges`.
73+
74+
Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent
75+
after it.
76+
77+
## The example app
78+
79+
`crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the
80+
redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One
81+
mechanism per decision, and exactly one branch runs.
82+
83+
Verified: 11 new engine/executor tests (including the reported repro in both
84+
directions), 12 new linter tests; `@objectstack/service-automation` 577 tests
85+
and `@objectstack/cli` 652 tests green, all three example apps build with no new
86+
findings.

content/docs/automation/flows.mdx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,61 @@ Edges connect nodes and define the execution path:
731731
| `type` | `enum` | optional | `'default'` (success), `'fault'` (error), `'conditional'` (expression-guarded), or `'back'` (declared back-edge, ADR-0044); defaults to `'default'` |
732732
| `condition` | `string` | optional | Boolean CEL predicate for branching (a bare string is stored as `{ dialect: 'cel', source }`) |
733733
| `label` | `string` | optional | Label displayed on the connector — cosmetic only. It does **not** select a path except on a branching node (`decision` / `approval`), which picks its out-edge by label. |
734-
| `isDefault` | `boolean` | optional | BPMN default-flow marker (interop). Accepted by the schema, but **the engine does not read it** — traversal selects by `condition` and by `label`. On a `decision` node, the fallback is the out-edge labelled `default`: when no `conditions[]` entry matches, the node emits `branchLabel: 'default'` |
734+
| `isDefault` | `boolean` | optional | BPMN default flow — the **"otherwise" branch**. Traversed only when no sibling `condition` on the same source node matched; never part of the unconditional parallel fan-out. Mutually exclusive with `condition`, at most one per node |
735+
736+
### Branching — pick one mechanism per node
737+
738+
A node has exactly two ways to split its path, and mixing them is what makes a
739+
guard stop guarding (#4414).
740+
741+
**Branch on the edges** (BPMN exclusive gateway — the default choice):
742+
743+
```typescript
744+
{ id: 'check', type: 'decision', label: 'Already converted?' }, // no config
745+
//
746+
edges: [
747+
{ id: 'e_yes', source: 'check', target: 'abort', condition: "lead.status == 'converted'", label: 'Yes' },
748+
{ id: 'e_no', source: 'check', target: 'proceed', isDefault: true, label: 'No' },
749+
]
750+
```
751+
752+
`e_no` runs **only** when `e_yes`'s condition was false. Drop `isDefault` and
753+
`e_no` becomes an ordinary unconditional out-edge that runs on **every** pass —
754+
in parallel with `abort` — so an already-converted lead sees the abort screen
755+
*and* walks into the wizard behind it. Writing the negation of every sibling
756+
condition by hand is the only other correct spelling; `isDefault` is the one
757+
that stays correct when a third branch is added.
758+
759+
**Branch on the node** (Salesforce-style decision outcomes): the node declares
760+
`config.conditions[]` and traversal restricts itself to the out-edge whose
761+
`label` matches the first matching entry.
762+
763+
```typescript
764+
{ id: 'check', type: 'decision', config: { conditions: [
765+
{ label: 'Yes', expression: "lead.status == 'converted'" },
766+
] } },
767+
edges: [
768+
{ id: 'e_yes', source: 'check', target: 'abort', label: 'Yes' },
769+
{ id: 'e_no', source: 'check', target: 'proceed', isDefault: true },
770+
]
771+
```
772+
773+
The labels must match **exactly**. When no declared condition matches, the node
774+
reports the branch `default`, claimed by an out-edge labelled `'default'` or by
775+
the `isDefault` edge. A branch label that no out-edge carries cannot route: the
776+
engine logs a warning and falls back to considering every out-edge. That
777+
fallback used to be silent, and a decision declaring `'Yes — already converted'`
778+
against an out-edge labelled `'Yes'` is how #4414 shipped. `os validate` reports
779+
the shape as `flow-branch-label-unmatched` at build time, along with
780+
`flow-decision-unconditional-branch` (a guarded decision with an unconditional
781+
sibling), `flow-default-edge-with-condition` and `flow-multiple-default-edges`.
782+
783+
<Callout type="warn">
784+
A decision node that declares **no** `conditions` reports no branch at all — it
785+
is a plain gateway and its out-edges do the routing. Do not declare both:
786+
`config.conditions` *and* per-edge `condition`s on the same node means the node
787+
picks a branch, and then that branch's edge re-decides.
788+
</Callout>
735789

736790
### Fault edges — handling a failed node
737791

content/docs/references/automation/flow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const result = Flow.parse(data);
8484
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) returning boolean used for branching. |
8585
| **type** | `Enum<'default' \| 'fault' \| 'conditional' \| 'back'>` | optional | Connection type: default (normal flow), fault (error path), conditional (expression-guarded), or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG cycle validation so a revise/rework loop can re-enter an earlier node) |
8686
| **label** | `string` | optional | Label on the connector |
87-
| **isDefault** | `boolean` | optional | Marks this edge as the default path when no other conditions match |
87+
| **isDefault** | `boolean` | optional | BPMN default flow: traverse this edge only when no sibling conditional edge of the same source node matched. Mutually exclusive with `condition`; at most one per source node. |
8888

8989

9090
---

content/docs/references/automation/schemaless-node-config.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ const result = DecisionCondition.parse(data);
121121

122122
| Property | Type | Required | Description |
123123
| :--- | :--- | :--- | :--- |
124-
| **label** | `string` || Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path) |
124+
| **label** | `string` || Branch label; the winning branch resumes down the out-edge with this label (no match → the out-edge marked isDefault, or one labelled 'default') |
125125
| **expression** | `string` || Bare CEL predicate deciding this branch |
126126

127127

examples/app-crm/src/flows/convert-lead.flow.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { defineFlow } from '@objectstack/spec';
1313
* children, atomically), and resumes the run with the new record's id bound to
1414
* `config.idVariable`.
1515
*
16-
* start → get_lead → decision (already converted?)
17-
* → screen_already_converted (abort path)
16+
* start → get_lead → decision (already converted?) — exclusive, exactly one:
17+
* → screen_already_converted (abort path, when status == 'converted')
1818
* → screen_account (Step 1 — full Customer form → account_id)
1919
* → screen_opportunity (Step 2 — full Opportunity form WITH product
2020
* line-items grid, prefilled account → opportunity_id)
@@ -67,16 +67,19 @@ export const ConvertLeadScreenFlow = defineFlow({
6767
},
6868

6969
// ── 3. Guard: already converted? ──────────────────────────────────────
70+
// A plain exclusive gateway: the branching lives on the OUT-EDGES (`e3a`'s
71+
// CEL condition, `e3b`'s `isDefault`) — ONE mechanism, not two.
72+
//
73+
// It used to ALSO declare `config.conditions` with its own labels, and that
74+
// guard did not guard (#4414). Those labels (`'Yes — already converted'` /
75+
// `'No — proceed'`) matched no out-edge label (`'Yes'` / `'No'`), so the
76+
// branch the node computed was dropped and every out-edge was considered
77+
// instead — and `e3b` was unconditional, so an already-converted lead got
78+
// the abort screen AND walked straight into the wizard behind it.
7079
{
7180
id: 'check_converted',
7281
type: 'decision',
7382
label: 'Already Converted?',
74-
config: {
75-
conditions: [
76-
{ label: 'Yes — already converted', expression: "{lead_record.status} == 'converted'" },
77-
{ label: 'No — proceed', expression: 'true' },
78-
],
79-
},
8083
},
8184

8285
// ── 3a. Already-converted abort screen ────────────────────────────────
@@ -155,9 +158,13 @@ export const ConvertLeadScreenFlow = defineFlow({
155158
edges: [
156159
{ id: 'e1', source: 'start', target: 'get_lead', type: 'default' },
157160
{ id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' },
158-
// guard branches
161+
// Guard branches — mutually exclusive. `e3a` carries the CEL predicate;
162+
// `e3b` is the BPMN default flow (`isDefault`), traversed ONLY when no
163+
// conditional sibling matched. Without that marker `e3b` is an ordinary
164+
// unconditional out-edge and runs on every pass, wizard and abort screen
165+
// together (#4414).
159166
{ id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' },
160-
{ id: 'e3b', source: 'check_converted', target: 'screen_account', type: 'default', label: 'No' },
167+
{ id: 'e3b', source: 'check_converted', target: 'screen_account', type: 'default', isDefault: true, label: 'No' },
161168
{ id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' },
162169
// main path — full Customer form → full Opportunity form → link
163170
{ id: 'e4', source: 'screen_account', target: 'screen_opportunity', type: 'default' },

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

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
FLOW_APPROVAL_REVISE_DISABLED,
1414
FLOW_RUNAS_UNSCOPED,
1515
FLOW_ERROR_LABEL_NOT_FAULT,
16+
FLOW_BRANCH_LABEL_UNMATCHED,
17+
FLOW_DECISION_UNCONDITIONAL_BRANCH,
18+
FLOW_DEFAULT_EDGE_WITH_CONDITION,
19+
FLOW_MULTIPLE_DEFAULT_EDGES,
1620
} from './lint-flow-patterns.js';
1721

1822
const CEL = (source: string) => ({ dialect: 'cel', source });
@@ -448,3 +452,156 @@ describe('flow-error-label-not-fault (#3863)', () => {
448452
},
449453
);
450454
});
455+
456+
/**
457+
* #4414 — a decision that declares a branch it cannot route.
458+
*
459+
* `guardFlow` is `examples/app-crm/src/flows/convert-lead.flow.ts`'s guard,
460+
* reduced: one CEL-guarded abort branch and one fallback branch.
461+
*/
462+
function guardFlow(opts: {
463+
conditions?: Array<{ label: string; expression: string }>;
464+
proceed?: Record<string, unknown>;
465+
extra?: Array<Record<string, unknown>>;
466+
} = {}) {
467+
return {
468+
flows: [{
469+
name: 'convert_lead',
470+
type: 'screen',
471+
nodes: [
472+
{ id: 'start', type: 'start', config: {} },
473+
{
474+
id: 'check', type: 'decision',
475+
...(opts.conditions ? { config: { conditions: opts.conditions } } : {}),
476+
},
477+
{ id: 'abort', type: 'screen', config: {} },
478+
{ id: 'proceed', type: 'screen', config: {} },
479+
],
480+
edges: [
481+
{ id: 'e1', source: 'start', target: 'check' },
482+
{ id: 'e_yes', source: 'check', target: 'abort', label: 'Yes', condition: "lead.status == 'converted'" },
483+
{ id: 'e_no', source: 'check', target: 'proceed', label: 'No', ...(opts.proceed ?? {}) },
484+
...(opts.extra ?? []),
485+
],
486+
}],
487+
};
488+
}
489+
490+
describe('flow-branch-label-unmatched (#4414)', () => {
491+
// The shipped shape: labels `'Yes — already converted'` / `'No — proceed'`
492+
// against out-edges labelled `'Yes'` / `'No'` — zero matches.
493+
it('flags decision branch labels no out-edge carries', () => {
494+
const fnds = lintFlowPatterns(guardFlow({
495+
proceed: { isDefault: true },
496+
conditions: [
497+
{ label: 'Yes — already converted', expression: "lead.status == 'converted'" },
498+
{ label: 'No — proceed', expression: 'true' },
499+
],
500+
})).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED);
501+
502+
expect(fnds).toHaveLength(1);
503+
expect(fnds[0].where).toContain("decision 'check'");
504+
expect(fnds[0].message).toContain("'yes — already converted'");
505+
expect(fnds[0].message).toContain("'no — proceed'");
506+
// The consequence, not just the mismatch.
507+
expect(fnds[0].message).toContain('EVERY');
508+
});
509+
510+
it('flags a partial mismatch — one claimed label does not excuse the other', () => {
511+
const fnds = lintFlowPatterns(guardFlow({
512+
proceed: { isDefault: true },
513+
conditions: [
514+
{ label: 'Yes', expression: "lead.status == 'converted'" },
515+
{ label: 'No — proceed', expression: 'true' },
516+
],
517+
})).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED);
518+
expect(fnds).toHaveLength(1);
519+
// Only the unclaimed label is reported as unclaimed; `'yes'` still shows up
520+
// later in the message as one of the out-edge labels that DO exist.
521+
expect(fnds[0].message).toMatch(/declares branch label\(s\) 'no proceed' that no out-edge/);
522+
});
523+
524+
it('does NOT flag labels that match (case/whitespace-insensitively)', () => {
525+
expect(lintFlowPatterns(guardFlow({
526+
proceed: { isDefault: true },
527+
conditions: [{ label: ' yes ', expression: 'true' }],
528+
})).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED)).toHaveLength(0);
529+
});
530+
531+
it('does NOT flag a decision that declares no conditions at all', () => {
532+
expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0);
533+
});
534+
});
535+
536+
describe('flow-decision-unconditional-branch (#4414)', () => {
537+
// The actual hole: `e_no` has no condition and no `isDefault`, so it is
538+
// traversed on every pass — the abort screen AND the wizard behind it.
539+
it('flags an unconditional out-edge alongside a guarded one', () => {
540+
const fnds = lintFlowPatterns(guardFlow()).filter(
541+
(f) => f.rule === FLOW_DECISION_UNCONDITIONAL_BRANCH,
542+
);
543+
expect(fnds).toHaveLength(1);
544+
expect(fnds[0].message).toContain("'proceed'");
545+
expect(fnds[0].message).toContain('EVERY pass');
546+
expect(fnds[0].hint).toContain('isDefault');
547+
});
548+
549+
it('does NOT flag once the fallback is marked isDefault — the fix', () => {
550+
expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0);
551+
});
552+
553+
it('does NOT flag once the fallback carries its own condition', () => {
554+
expect(lintFlowPatterns(guardFlow({
555+
proceed: { condition: "lead.status != 'converted'" },
556+
}))).toHaveLength(0);
557+
});
558+
559+
it('does NOT flag an edge the decision CAN select by declared label', () => {
560+
expect(lintFlowPatterns(guardFlow({
561+
conditions: [{ label: 'No', expression: 'true' }, { label: 'Yes', expression: 'false' }],
562+
})).filter((f) => f.rule === FLOW_DECISION_UNCONDITIONAL_BRANCH)).toHaveLength(0);
563+
});
564+
565+
it('does NOT flag a decision with no guarded edge at all — nothing to undercut', () => {
566+
expect(lintFlowPatterns({
567+
flows: [{
568+
name: 'plain',
569+
nodes: [{ id: 'start', type: 'start', config: {} }, { id: 'check', type: 'decision' }, { id: 'a', type: 'screen', config: {} }],
570+
edges: [
571+
{ id: 'e1', source: 'start', target: 'check' },
572+
{ id: 'e2', source: 'check', target: 'a' },
573+
],
574+
}],
575+
})).toHaveLength(0);
576+
});
577+
578+
it('does NOT flag a fault edge — error routing is not branch selection', () => {
579+
expect(lintFlowPatterns(guardFlow({
580+
proceed: { isDefault: true },
581+
extra: [{ id: 'e_err', source: 'check', target: 'abort', type: 'fault' }],
582+
}))).toHaveLength(0);
583+
});
584+
});
585+
586+
describe('flow-default-edge-with-condition / flow-multiple-default-edges (#4414)', () => {
587+
it('flags an edge that is both the default and conditional', () => {
588+
const fnds = lintFlowPatterns(guardFlow({
589+
proceed: { isDefault: true, condition: "lead.status != 'converted'" },
590+
})).filter((f) => f.rule === FLOW_DEFAULT_EDGE_WITH_CONDITION);
591+
expect(fnds).toHaveLength(1);
592+
expect(fnds[0].message).toContain('contradictory');
593+
});
594+
595+
it('flags two default edges out of one node', () => {
596+
const fnds = lintFlowPatterns(guardFlow({
597+
proceed: { isDefault: true },
598+
extra: [{ id: 'e_also', source: 'check', target: 'abort', isDefault: true }],
599+
})).filter((f) => f.rule === FLOW_MULTIPLE_DEFAULT_EDGES);
600+
expect(fnds).toHaveLength(1);
601+
expect(fnds[0].where).toContain("node 'check'");
602+
});
603+
604+
it('does NOT flag one default edge per node', () => {
605+
expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0);
606+
});
607+
});

0 commit comments

Comments
 (0)