Skip to content

Commit 368937c

Browse files
docs: orphaned-flow implementation plan (Lane C) (#97)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 3e8e66e commit 368937c

1 file changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# Orphaned Flow — Implementation Plan
2+
3+
**Status:** Plan
4+
**Date:** 2026-06-26
5+
**Author:** Big Pickle
6+
**Affected package:** `@intent-framework/core`
7+
**Related docs:** `docs/proposals/Reachability-Diagnostics.md`
8+
9+
---
10+
11+
## 1. Problem
12+
13+
`inspectScreen()` currently emits a `surfaced-node-not-in-any-flow` diagnostic when a surfaced ask/act is unreferenced by any flow step. However, there is no corresponding diagnostic for the inverse: a flow whose steps reference only nodes that are not in any surface.
14+
15+
Such a flow is **orphaned** — every step it defines produces no visible output. The author defined an interaction path that leads nowhere. This is the flow-level analog of `ask-not-in-surface` / `action-not-in-surface`.
16+
17+
---
18+
19+
## 2. Goal
20+
21+
Add an `orphaned-flow` diagnostic to `computeDiagnostics()` in `packages/core/src/graph.ts` that fires when a flow exists but none of its steps reference a node that appears in any surface.
22+
23+
### Design (from Reachability-Diagnostics.md)
24+
25+
| Field | Value |
26+
|-------|-------|
27+
| **Severity** | `warning` |
28+
| **Code** | `orphaned-flow` |
29+
| **Applies to** | Flow nodes |
30+
| **Triggers when** | A flow exists but none of its steps reference nodes that are in any surface. |
31+
32+
---
33+
34+
## 3. Algorithm
35+
36+
Given `screenDef.flows`, `screenDef.surfaces`:
37+
38+
```
39+
surfacedNodeIds = union of all surface.items[*].id
40+
41+
for each flow in screenDef.flows:
42+
if flow.steps is empty:
43+
emit orphaned-flow (empty flow — all zero steps are unsurfaced)
44+
continue
45+
46+
flowHasSurfacedStep = false
47+
for each step in flow.steps:
48+
if step.node.id ∈ surfacedNodeIds:
49+
flowHasSurfacedStep = true
50+
break
51+
52+
if not flowHasSurfacedStep:
53+
emit orphaned-flow
54+
```
55+
56+
### Rationale for empty flows
57+
58+
A flow defined without `.startsWith()` (e.g. `$.flow("empty")` with no chained calls) has zero steps. Zero steps means zero surfaced nodes — the flow is trivially orphaned. This is consistent with the "every step unsurfaced" check.
59+
60+
If the team prefers a separate `empty-flow` diagnostic in the future, the orphaned check can gate on `flow.steps.length > 0`. For now, treat empty flows as orphaned.
61+
62+
---
63+
64+
## 4. Implementation Steps
65+
66+
### Step 1: Add the diagnostic emission in `computeDiagnostics()`
67+
68+
Location: `packages/core/src/graph.ts`, function `computeDiagnostics()`.
69+
70+
After the existing flow loop (lines 161–189, which emit `surfaced-node-not-in-any-flow`), add a second loop:
71+
72+
```ts
73+
for (const flow of screenDef.flows) {
74+
const flowSurfaced = flow.steps.some(step => surfacedNodeIds.has(step.node.id))
75+
if (!flowSurfaced) {
76+
diagnostics.push({
77+
severity: "warning",
78+
code: "orphaned-flow",
79+
message: `Flow "${flow.name}" has no steps that reference surfaced nodes.`,
80+
nodeId: flow.id,
81+
})
82+
}
83+
}
84+
```
85+
86+
### Step 2: Add `flowSemanticNodeId` enrichment
87+
88+
In `inspectScreen()` (line 195), the `augmentedDiagnostics` map currently enriches `nodeId``semanticNodeId` for diagnostics. The `orphaned-flow` diagnostic references a flow node, not an ask/act node, so the existing `idToSemantic` map (which only maps ask and act IDs) will not cover it.
89+
90+
If the team wants `semanticNodeId` populated for orphaned-flow diagnostics, the flow IDs need their own semantic mapping. Add after the existing ask/act mapping (line 207):
91+
92+
```ts
93+
// in inspectScreen(), after idToSemantic is built for asks and acts
94+
const flowIdToSemantic = new Map<string, string>()
95+
for (const f of screenDef.flows) {
96+
flowIdToSemantic.set(f.id, flowIds(f.name))
97+
}
98+
```
99+
100+
Then when enriching diagnostics that have `nodeId` referring to a flow, use `flowIdToSemantic` as a fallback, or check both maps.
101+
102+
Alternatively, add a new optional `flowNodeId` / `flowSemanticNodeId` field to `GraphDiagnostic`. The `Reachability-Diagnostics.md` proposal discusses this but it is not required for the `orphaned-flow` diagnostic to function — the semantic enrichment is a nice-to-have for tooling.
103+
104+
### Step 3: Test cases
105+
106+
Location: `packages/core/src/core.test.ts`, under the `graph diagnostics > reachability diagnostics` describe block.
107+
108+
#### Test 3a: Flow with all steps unsurfaced emits orphaned-flow
109+
110+
```ts
111+
it("emits orphaned-flow when no flow step references a surfaced node", () => {
112+
const screenDef = screen("OrphanedFlow", $ => {
113+
const email = $.state.text("email")
114+
const emailAsk = $.ask("Email", email).private()
115+
const login = $.act("Log in")
116+
$.flow("orphan-flow").startsWith(emailAsk).then(login)
117+
// Nothing is surfaced
118+
})
119+
const inspected = inspectScreen(screenDef)
120+
const diags = inspected.diagnostics.filter(d => d.code === "orphaned-flow")
121+
expect(diags).toHaveLength(1)
122+
expect(diags[0]?.severity).toBe("warning")
123+
expect(diags[0]?.nodeId).toBe("flow_orphan-flow")
124+
expect(diags[0]?.message).toContain("orphan-flow")
125+
})
126+
```
127+
128+
#### Test 3b: Flow with at least one surfaced step does not emit orphaned-flow
129+
130+
```ts
131+
it("does not emit orphaned-flow when at least one step references a surfaced node", () => {
132+
const screenDef = screen("NotOrphaned", $ => {
133+
const email = $.state.text("email")
134+
const emailAsk = $.ask("Email", email).private()
135+
const login = $.act("Log in")
136+
$.flow("ok-flow").startsWith(emailAsk).then(login)
137+
$.surface("main").contains(emailAsk)
138+
})
139+
const inspected = inspectScreen(screenDef)
140+
const diags = inspected.diagnostics.filter(d => d.code === "orphaned-flow")
141+
expect(diags).toHaveLength(0)
142+
})
143+
```
144+
145+
#### Test 3c: Empty flow emits orphaned-flow
146+
147+
```ts
148+
it("emits orphaned-flow for empty flow", () => {
149+
const screenDef = screen("EmptyFlow", $ => {
150+
$.flow("empty")
151+
$.surface("main")
152+
})
153+
const inspected = inspectScreen(screenDef)
154+
const diags = inspected.diagnostics.filter(d => d.code === "orphaned-flow")
155+
expect(diags).toHaveLength(1)
156+
expect(diags[0]?.nodeId).toBe("flow_empty")
157+
})
158+
```
159+
160+
#### Test 3d: Multiple flows — only orphaned ones emit the diagnostic
161+
162+
```ts
163+
it("emits orphaned-flow only for flows with no surfaced steps when multiple flows exist", () => {
164+
const screenDef = screen("MultiFlow", $ => {
165+
const email = $.state.text("email")
166+
const emailAsk = $.ask("Email", email).private()
167+
const login = $.act("Log in")
168+
const hiddenAct = $.act("Hidden")
169+
$.flow("good-flow").startsWith(emailAsk).then(login)
170+
$.flow("orphan-flow").startsWith(hiddenAct)
171+
$.surface("main").contains(emailAsk, login)
172+
})
173+
const inspected = inspectScreen(screenDef)
174+
const diags = inspected.diagnostics.filter(d => d.code === "orphaned-flow")
175+
expect(diags).toHaveLength(1)
176+
expect(diags[0]?.nodeId).toBe("flow_orphan-flow")
177+
})
178+
```
179+
180+
#### Test 3e: Deterministic ordering with existing diagnostics
181+
182+
```ts
183+
it("orphaned-flow diagnostic is deterministic alongside other diagnostics", () => {
184+
const screenDef = screen("DeterministicOrphan", $ => {
185+
const email = $.state.text("email")
186+
const emailAsk = $.ask("Email", email).private()
187+
const login = $.act("Log in")
188+
$.flow("orphan").startsWith(emailAsk).then(login)
189+
// nothing surfaced — multiple diagnostics expected
190+
})
191+
const first = inspectScreen(screenDef)
192+
const second = inspectScreen(screenDef)
193+
expect(first.diagnostics.map(d => d.code)).toEqual(second.diagnostics.map(d => d.code))
194+
expect(first.diagnostics.some(d => d.code === "orphaned-flow")).toBe(true)
195+
})
196+
```
197+
198+
---
199+
200+
## 5. Interaction with Existing Diagnostics
201+
202+
| Diagnostic | Relationship to `orphaned-flow` |
203+
|---|---|
204+
| `ask-not-in-surface` | Both will fire for an ask referenced only in orphaned flow steps. The author sees the unsurfaced ask warning (generic) plus the orphaned flow warning (specific to the flow). This is intentionally redundant — different angles on the same problem. |
205+
| `action-not-in-surface` | Same as above, for action nodes. |
206+
| `surfaced-node-not-in-any-flow` | Inverse — this fires for surfaced nodes with no flow reference; `orphaned-flow` fires for flows with no surfaced nodes. They can coexist on the same screen (surfaced nodes not in any flow, and flows referencing only unsurfaced nodes). |
207+
208+
No existing diagnostic should be removed or modified.
209+
210+
---
211+
212+
## 6. Files to Change
213+
214+
| File | Change |
215+
|------|--------|
216+
| `packages/core/src/graph.ts` | Add orphaned-flow emission in `computeDiagnostics()` (lines 161–189 block). Optionally add flow semantic ID enrichment in `inspectScreen()`. |
217+
| `packages/core/src/core.test.ts` | Add test cases (see section 4, step 3). |
218+
219+
### No changes needed to
220+
221+
- `packages/core/src/flow.ts``FlowNode.steps` already contains the node references needed.
222+
- `packages/core/src/surface.ts``SurfaceNode.items` already contains the node references needed.
223+
- `packages/core/src/act.ts`, `ask.ts` — Node `id` fields are stable at inspection time.
224+
- `packages/core/src/screen.ts` — No changes to the builder API.
225+
- `packages/core/src/registry.ts` — No new registry types needed.
226+
- Any other package — The diagnostic is purely in `@intent-framework/core`.
227+
228+
---
229+
230+
## 7. Open Questions
231+
232+
1. **Should empty flows emit `orphaned-flow` or a separate `empty-flow`?** This plan says `orphaned-flow` for now. If the team prefers a distinct code, split: `orphaned-flow` for non-empty flows with all unsurfaced steps, `empty-flow` for zero-step flows.
233+
234+
2. **Should `orphaned-flow` include `flowNodeId` / `flowSemanticNodeId`?** The existing `GraphDiagnostic` shape (`nodeId`, `semanticNodeId`) is sufficient for now — the `nodeId` will reference the flow ID. Adding dedicated flow fields is forward-looking and not required for this diagnostic to be actionable.
235+
236+
3. **Should `orphaned-flow` fire when there are zero surfaces?** Yes, because `surfacedNodeIds` will be empty, and every step is trivially unsurfaced. This is consistent with the algorithm.
237+
238+
---
239+
240+
## 8. Verification
241+
242+
After implementation, run:
243+
244+
```sh
245+
pnpm test # new tests pass, existing tests unchanged
246+
pnpm typecheck # no type errors
247+
pnpm build # compiles cleanly
248+
pnpm lint # no lint errors
249+
pnpm pack:check # packing is valid
250+
pnpm changeset status # no unexpected changeset state
251+
```
252+
253+
No changeset is needed for this lane (plan-only).
254+
255+
---
256+
257+
## 9. Related
258+
259+
- `docs/proposals/Reachability-Diagnostics.md` — parent proposal covering all four reachability diagnostics
260+
- `packages/core/src/graph.ts:96``computeDiagnostics()` function
261+
- `packages/core/src/graph.ts:161` — existing flows section (where orphaned-flow will be added)
262+
- `packages/core/src/core.test.ts:738` — existing reachability diagnostics tests

0 commit comments

Comments
 (0)