Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/31224-flow-node-unreachable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/lint': minor
---

fix(lint)!: a flow node no edge path from the start node reaches is refused at build and publish (`flow-node-unreachable`), because a run never executes it

Clause-②: yes (narrowing) — `os validate`, `os build`, `os lint` and the flow save and publish doors refuse a flow they accepted before; `@objectstack/lint` also exports the rule id `FLOW_NODE_UNREACHABLE`, and `RULE_EXPLANATIONS` gains its entry.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` and `content/docs/**` teach only wired top-level flows; the edgeless examples in `content/docs/automation/flows.mdx` are region bodies, which this rule does not judge. The reasoning ships as the `os explain flow-node-unreachable` entry.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: FlowSchema keeps its shape and meaning, and lint now refuses a graph whose node no edge from the start node reaches. Such a node never ran (a run starts at the start node and follows edges), so there is no rewrite that keeps what the author meant: the author wires the node in or deletes it, and the finding says which node. -->

**BREAKING** accept-set narrowing in `os validate` / `os build` / `os lint` and at the flow publish doors (`PUT /api/v1/meta/flow/NAME` live save, the per-item and package publish; Studio, REST and MCP authors), through the gating `lintFlowPatterns` rule.

- **Before.** A flow whose top-level node had no edge path from the start node (`nodes: [start, stamp, end]`, `edges: [start → end]`) parsed, built, saved, published and registered, and reported itself as configured. The engine starts a run at the start node and follows edges, so `stamp` never ran, and nothing said so.
- **Now.** `flow-node-unreachable` (severity `error`) names each such node: `flow 'NAME' · node 'ID' (TYPE)`, why it never runs, and the fix. A draft save still stores, with the finding in its `_diagnostics`, so a designer that deletes a node and leaves its successor unwired can keep saving; the publish refuses it.
- **A second start node.** A run starts at the first start node, so a second one is reported too; its hint says to delete the extra start rather than wire an edge into it.
- **What counts as reaching a node.** Every edge type: default, conditional, fault and back. A `boundary_event` node, and the nodes only it leads to, are not judged (they are reached by attachment, not by an edge).
- **Not judged.** Nodes inside a region body (a region is a single-entry acyclic graph that the region validator already holds, so all of it is reached); a flow with no start node; a flow with an edge whose endpoint names no node (the flow parse refuses that first).
- **Why lint and not the flow parse.** The metadata save door parses `FlowSchema` on every save, drafts included, and the Studio designer's node delete can leave a successor unwired. A parse rule would refuse that draft save outright.

**What an author does.** Nothing for a flow whose nodes are all wired from the start node. For a refused node: add an edge into it from a node that runs, or delete the node. A refused node never ran, so neither choice changes what the flow did.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,7 @@ export {
FLOW_DEFAULT_EDGE_WITH_CONDITION,
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_NODE_UNREACHABLE,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
Expand Down
158 changes: 152 additions & 6 deletions packages/lint/src/lint-flow-patterns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
FLOW_DEFAULT_EDGE_WITH_CONDITION,
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_NODE_UNREACHABLE,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
Expand Down Expand Up @@ -91,7 +92,7 @@ const filterFlow = (filter: unknown) => ({
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
{ id: 'query', type: 'get_record', config: { objectName: 'contract', filter } },
],
edges: [],
edges: [{ id: 'e1', source: 'start', target: 'query' }],
}],
});

Expand Down Expand Up @@ -220,7 +221,7 @@ describe('lintFlowPatterns — phantom aggregation capability (#1870)', () => {
{ id: 'start', type: 'start', config: {} },
{ id: 'sum', type: 'script', config },
],
edges: [],
edges: [{ id: 'e1', source: 'start', target: 'sum' }],
}],
});

Expand Down Expand Up @@ -250,7 +251,7 @@ const nodeFlow = (config: Record<string, unknown>) => ({
{ id: 'start', type: 'start', config: {} },
{ id: 'create', type: 'create_record', config },
],
edges: [],
edges: [{ id: 'e1', source: 'start', target: 'create' }],
}],
});
const rules = (s: any) => lintFlowPatterns(s).map((f) => f.rule);
Expand Down Expand Up @@ -351,6 +352,7 @@ describe('lintFlowPatterns — approval revise loop (ADR-0044)', () => {
const edges = [
{ source: 'start', target: 'mgr' },
{ source: 'mgr', target: 'ok', label: 'approve' },
{ source: 'mgr', target: 'no', label: 'reject' },
{ source: 'mgr', target: 'wait', label: 'revise' },
];
const fnds = lintFlowPatterns(approvalFlow(edges));
Expand All @@ -370,7 +372,11 @@ describe('lintFlowPatterns — approval revise loop (ADR-0044)', () => {
{ source: 'mgr', target: 'ok', label: 'approve' },
{ source: 'mgr', target: 'no', label: 'reject' },
];
expect(lintFlowPatterns(approvalFlow(edges))).toEqual([]);
// No revise edge, so no revise node either: one left in place with no edge
// into it would draw `flow-node-unreachable` (it could never run).
const stack = approvalFlow(edges) as any;
stack.flows[0].nodes = stack.flows[0].nodes.filter((n: any) => n.id !== 'wait');
expect(lintFlowPatterns(stack)).toEqual([]);
});

// #3823 — the shape ADR-0044 D3 prescribed until its 2026-07-28 amendment.
Expand Down Expand Up @@ -416,7 +422,7 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
{ id: 'start', type: 'start', config: opts.startConfig ?? { triggerType: 'schedule', cron: '0 8 * * *' } },
{ id: 'op', type: opts.nodeType ?? 'create_record', config: { objectName: 'thing', fields: { a: 1 } } },
],
edges: [],
edges: [{ id: 'e1', source: 'start', target: 'op' }],
}],
});

Expand Down Expand Up @@ -528,7 +534,7 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
{ id: 'start', type: 'start', config: { schedule: { type: 'interval', intervalMs: 60000 } } },
{ id: 'notify', type: 'notify', config: { topic: 'x' } },
],
edges: [],
edges: [{ id: 'e1', source: 'start', target: 'notify' }],
}],
})).toHaveLength(0);
});
Expand Down Expand Up @@ -3710,3 +3716,143 @@ describe('[#22161] one-line fix — the twelve flow anti-pattern ids', () => {
}
});
});

describe('lintFlowPatterns — a node no edge path from start reaches (flow-node-unreachable)', () => {
type Edge = { id: string; source: string; target: string; type?: string; condition?: string };
const reachFlow = (nodes: Array<Record<string, unknown>>, edges: Edge[]) => ({
flows: [{ name: 'orphan', type: 'autolaunched', nodes, edges }],
});
const START = { id: 'start', type: 'start', label: 'Start' };
const END = { id: 'end', type: 'end', label: 'End' };
const unreachable = (stack: unknown) =>
lintFlowPatterns(stack as never).filter((f) => f.rule === FLOW_NODE_UNREACHABLE);

it('refuses an action node no edge reaches, as an error that names it, why it never runs and the fix', () => {
const found = unreachable(reachFlow(
[START, { id: 'stamp', type: 'assignment', label: 'Stamp' }, END],
[{ id: 'e1', source: 'start', target: 'end' }],
));
expect(found).toHaveLength(1);
expect(found[0]).toMatchObject({ rule: FLOW_NODE_UNREACHABLE, severity: 'error' });
expect(found[0].where).toBe("flow 'orphan' · node 'stamp' (assignment)");
expect(found[0].message).toMatch(/no edge path leads to this node from the start node 'start', so it never runs/);
expect(found[0].hint).toMatch(/Add an edge into it from a node that runs, or delete the node/);
});

it('refuses a node whose only in-edge comes from another unreached node (one finding each)', () => {
const found = unreachable(reachFlow(
[START, { id: 'a', type: 'assignment' }, { id: 'b', type: 'assignment' }, END],
[{ id: 'e1', source: 'start', target: 'end' }, { id: 'e2', source: 'a', target: 'b' }],
));
expect(found.map((f) => f.where)).toEqual([
"flow 'orphan' · node 'a' (assignment)",
"flow 'orphan' · node 'b' (assignment)",
]);
});

it('refuses a second start node with the cure that fits it: delete the extra start', () => {
const found = unreachable(reachFlow(
[START, { id: 'start2', type: 'start' }, { id: 'b', type: 'assignment' }, END],
[{ id: 'e1', source: 'start', target: 'end' }, { id: 'e2', source: 'start2', target: 'b' }],
));
expect(found.map((f) => f.where)).toEqual([
"flow 'orphan' · node 'start2' (start)",
"flow 'orphan' · node 'b' (assignment)",
]);
expect(found[0].hint).toMatch(/delete this extra start node/);
expect(found[1].hint).toMatch(/Add an edge into it from a node that runs/);
});

it('refuses a cycle cut off from start', () => {
const found = unreachable(reachFlow(
[START, { id: 'a', type: 'assignment' }, { id: 'b', type: 'assignment' }, END],
[
{ id: 'e1', source: 'start', target: 'end' },
{ id: 'e2', source: 'a', target: 'b' },
{ id: 'e3', source: 'b', target: 'a', type: 'back' },
],
));
expect(found).toHaveLength(2);
});

describe('does NOT flag (controls)', () => {
it('a node reached over a conditional, fault or back edge (every edge type counts)', () => {
expect(unreachable(reachFlow(
[
START,
{ id: 'call', type: 'http', config: { url: 'https://example.test' } },
{ id: 'big', type: 'assignment' },
{ id: 'recover', type: 'assignment' },
END,
],
[
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'big', type: 'conditional', condition: 'true' },
{ id: 'e3', source: 'call', target: 'recover', type: 'fault' },
{ id: 'e4', source: 'big', target: 'end' },
{ id: 'e5', source: 'recover', target: 'end' },
],
))).toEqual([]);
});

it('a boundary_event node and the nodes only it leads to (reached by attachment, not by an edge)', () => {
expect(unreachable(reachFlow(
[
START,
{ id: 'task', type: 'wait', waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } },
{ id: 'timeout', type: 'boundary_event', boundaryConfig: { attachedToNodeId: 'task', eventType: 'timer' } },
{ id: 'escalate', type: 'assignment' },
END,
],
[
{ id: 'e1', source: 'start', target: 'task' },
{ id: 'e2', source: 'task', target: 'end' },
{ id: 'e3', source: 'timeout', target: 'escalate' },
],
))).toEqual([]);
});

it('a flow with no start node (a different defect, judged elsewhere)', () => {
expect(unreachable(reachFlow(
[{ id: 'a', type: 'assignment' }, END],
[],
))).toEqual([]);
});

it('a flow with a dangling edge (the flow parse refuses that first; reachability is not guessed)', () => {
expect(unreachable(reachFlow(
[START, { id: 'a', type: 'assignment' }, END],
[{ id: 'e1', source: 'start', target: 'end' }, { id: 'e2', source: 'ghost', target: 'a' }],
))).toEqual([]);
});

it('nodes inside a region body (a region is a single-entry DAG, so every node in it is reached)', () => {
expect(unreachable(reachFlow(
[
START,
{
id: 'each', type: 'loop',
config: {
collection: '{records}', iteratorVariable: 'r',
body: {
nodes: [{ id: 'x', type: 'assignment' }, { id: 'y', type: 'assignment' }],
edges: [{ id: 'b1', source: 'x', target: 'y' }],
},
},
},
END,
],
[{ id: 'e1', source: 'start', target: 'each' }, { id: 'e2', source: 'each', target: 'end' }],
))).toEqual([]);
});
});

it('is in the gating flow rule, so its error reaches os lint/build and the save and publish doors', () => {
const rule = AUTHORING_RULES.find((r) => r.name === 'lintFlowPatterns');
expect(rule, 'the authoring rule that runs lintFlowPatterns').toBeDefined();
expect(rule!.tier).toBe('gating');
expect(rule!.runtimeTypes).toContain('flow');
const served = rule!.run(reachFlow([START, { id: 'stamp', type: 'assignment' }, END], [{ id: 'e1', source: 'start', target: 'end' }]) as never, {});
expect(served).toContainEqual(expect.objectContaining({ rule: FLOW_NODE_UNREACHABLE, severity: 'error' }));
});
});
92 changes: 92 additions & 0 deletions packages/lint/src/lint-flow-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export const FLOW_DEFAULT_EDGE_WITH_CONDITION = 'flow-default-edge-with-conditio
export const FLOW_MULTIPLE_DEFAULT_EDGES = 'flow-multiple-default-edges';
/** #4414 — `config.condition` on a node whose executor never reads it. */
export const FLOW_INERT_NODE_CONDITION = 'flow-inert-node-condition';
/**
* A top-level node no edge path from the start node reaches: it never runs.
* See {@link scanUnreachableNodes}.
*/
export const FLOW_NODE_UNREACHABLE = 'flow-node-unreachable';
/**
* #15429 — a `decision` `config.mode` the spec refuses: a value outside
* `'exclusive' | 'inclusive'`, or a legal `mode` beside a non-empty
Expand Down Expand Up @@ -1862,6 +1867,87 @@ function scanTryCatchWithoutCatch(
* never throws. A finding marked `severity: 'error'` fails the build, and since
* #5383 it can be raised by a node inside a `loop` body too.
*/
/**
* {@link FLOW_NODE_UNREACHABLE}: a top-level node no edge path from the start
* node reaches, one `error` finding per node.
*
* The engine runs a flow from its first `start` node and follows out-edges from
* there (`service-automation`'s `executeNode` → `traverseNext`); nothing else
* schedules a top-level node. So a node no path reaches (an edge forgotten while
* wiring, a node left behind when its in-edges were deleted) registers,
* publishes, reports itself configured and never runs: inert on every run,
* which is this module's bar for `error`.
*
* Why lint and not the flow parse: the save door parses `FlowSchema` on every
* save, a draft included, and the Studio designer produces this shape mid-edit
* (removing a node that has more or fewer than one in-edge and out-edge drops
* its edges without bridging them, which leaves its successors unwired). A
* parse refusal would refuse that draft. As a finding here it fails `os lint` /
* `os build`, and on the runtime authoring gate (`runtimeTypes: ['flow']`) a
* live save and a publish, while a draft is kept with it in `_diagnostics`.
*
* Scope, declared rather than discovered:
*
* - The top-level graph only (see the call site).
* - A flow with no `start` node is not judged: nothing is reached from
* nothing, and the engine refuses to run it (`FLOW_NO_START_NODE`).
* - Every edge counts, whatever its `type` or `condition`: a `fault` edge or a
* conditional edge is a path the engine can take.
* - A flow with an edge whose endpoint names no top-level node is not judged:
* the flow parse refuses that edge, and the node it was meant to reach would
* be reported a second time, for the same fault, under a cure (add an edge)
* that is not the one to apply.
* - A `boundary_event` is exempt, and so is a node reached only through one: it
* is attached to its host by `boundaryConfig.attachedToNodeId`, not entered by
* an edge, and its own contract owns its refusal.
*/
function scanUnreachableNodes(
flowName: string,
nodes: AnyRec[],
edges: AnyRec[],
findings: FlowLintFinding[],
): void {
const start = nodes.find((n) => n.type === 'start');
if (!start || typeof start.id !== 'string') return;
const ids = new Set(nodes.map((n) => n.id).filter((id): id is string => typeof id === 'string'));
const outgoing = new Map<string, string[]>();
for (const e of edges) {
if (typeof e.source !== 'string' || typeof e.target !== 'string') return;
if (!ids.has(e.source) || !ids.has(e.target)) return;
const targets = outgoing.get(e.source) ?? [];
targets.push(e.target);
outgoing.set(e.source, targets);
}
const reached = new Set<string>();
const visit = (from: readonly string[]): void => {
const queue = [...from];
while (queue.length > 0) {
const id = queue.shift()!;
if (reached.has(id)) continue;
reached.add(id);
queue.push(...(outgoing.get(id) ?? []));
}
};
visit([start.id]);
visit(nodes.filter((n) => n.type === 'boundary_event' && typeof n.id === 'string').map((n) => n.id as string));
for (const node of nodes) {
if (typeof node.id !== 'string' || reached.has(node.id)) continue;
findings.push({
where: `flow '${flowName}' · node '${node.id}' (${String(node.type)})`,
message:
`no edge path leads to this node from the start node '${start.id}', so it never runs: a run starts ` +
`at the start node and follows edges, and the flow still registers and reports itself as configured.`,
// A second `start` node is unreached by construction (a run starts at the
// first one), and an edge into it is not the cure: remove the extra start.
hint: node.type === 'start'
? `A run starts at the first start node only: delete this extra start node, and wire what follows it from a node that runs.`
: `Add an edge into it from a node that runs, or delete the node.`,
rule: FLOW_NODE_UNREACHABLE,
severity: 'error',
});
}
}

export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
const findings: FlowLintFinding[] = [];
for (const flow of recordsOf(stack.flows)) {
Expand Down Expand Up @@ -1967,6 +2053,12 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
}
}

// (a5) A top-level node no edge path from the start node reaches never runs.
// Flow-level for the reason the trigger rules are: a run enters the
// top-level graph at its start node, and a region body is already
// single-entry (ADR-0031), so nothing one level down can be unreached.
scanUnreachableNodes(flowName, nodes, edges, findings);

// (b)–(e) #5383 — every graph in the flow, not just the top-level one: its
// own `nodes`/`edges` plus each nested ADR-0031 region, each scanned
// against ITS OWN edge list. `scope` is empty for the flow's own graph,
Expand Down
Loading
Loading