Skip to content

Commit b76a1e0

Browse files
authored
feat(core): implement flow-step-not-surfaced diagnostic (#105)
1 parent 95cb82f commit b76a1e0

3 files changed

Lines changed: 180 additions & 1 deletion

File tree

.changeset/fresh-jokes-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intent-framework/core": minor
3+
---
4+
5+
Add `flow-step-not-surfaced` diagnostic to `inspectScreen` for flow steps missing from surfaces.

packages/core/src/core.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,155 @@ describe("graph diagnostics", () => {
831831
expect(diags[0]?.semanticNodeId).toBe("action:hidden")
832832
})
833833
})
834+
835+
describe("flow-step-not-surfaced diagnostic", () => {
836+
it("does not emit flow-step-not-surfaced when all flow steps are surfaced", () => {
837+
const screenDef = screen("AllStepsSurfaced", $ => {
838+
const email = $.state.text("email")
839+
const emailAsk = $.ask("Email", email).private()
840+
const login = $.act("Log in")
841+
$.surface("main").contains(emailAsk, login)
842+
$.flow("login").startsWith(emailAsk).then(login)
843+
})
844+
const inspected = inspectScreen(screenDef)
845+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
846+
expect(diags).toHaveLength(0)
847+
})
848+
849+
it("emits flow-step-not-surfaced for unsurfaced ask step in flow", () => {
850+
const screenDef = screen("UnsurfacedAskInFlow", $ => {
851+
const email = $.state.text("email")
852+
const emailAsk = $.ask("Email", email).private()
853+
const login = $.act("Log in")
854+
$.surface("main").contains(login)
855+
$.flow("login").startsWith(emailAsk).then(login)
856+
})
857+
const inspected = inspectScreen(screenDef)
858+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
859+
expect(diags).toHaveLength(1)
860+
expect(diags[0]?.severity).toBe("warning")
861+
expect(diags[0]?.nodeId).toBe("ask_email")
862+
})
863+
864+
it("emits flow-step-not-surfaced for unsurfaced action step in flow", () => {
865+
const screenDef = screen("UnsurfacedActInFlow", $ => {
866+
const email = $.state.text("email")
867+
const emailAsk = $.ask("Email", email).private()
868+
const login = $.act("Log in")
869+
$.surface("main").contains(emailAsk)
870+
$.flow("login").startsWith(emailAsk).then(login)
871+
})
872+
const inspected = inspectScreen(screenDef)
873+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
874+
expect(diags).toHaveLength(1)
875+
expect(diags[0]?.severity).toBe("warning")
876+
expect(diags[0]?.nodeId).toBe("act_log_in")
877+
})
878+
879+
it("only reports unsurfaced steps when some are surfaced and some are not", () => {
880+
const screenDef = screen("MixedSurfaced", $ => {
881+
const email = $.state.text("email")
882+
const pwd = $.state.text("password")
883+
const emailAsk = $.ask("Email", email).private()
884+
const pwdAsk = $.ask("Password", pwd).private()
885+
const login = $.act("Log in")
886+
$.surface("main").contains(emailAsk, login)
887+
$.flow("login").startsWith(emailAsk).then(pwdAsk).then(login)
888+
})
889+
const inspected = inspectScreen(screenDef)
890+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
891+
expect(diags).toHaveLength(1)
892+
expect(diags[0]?.nodeId).toBe("ask_password")
893+
})
894+
895+
it("does not emit flow-step-not-surfaced when there are no flows", () => {
896+
const screenDef = screen("NoFlowsAtAll", $ => {
897+
const email = $.state.text("email")
898+
const emailAsk = $.ask("Email", email).private()
899+
const login = $.act("Log in")
900+
$.surface("main").contains(emailAsk, login)
901+
})
902+
const inspected = inspectScreen(screenDef)
903+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
904+
expect(diags).toHaveLength(0)
905+
})
906+
907+
it("emits deterministic flow-step-not-surfaced diagnostics across multiple flows", () => {
908+
const screenDef = screen("MultipleFlowsDet", $ => {
909+
const email = $.state.text("email")
910+
const pwd = $.state.text("password")
911+
const emailAsk = $.ask("Email", email).private()
912+
const pwdAsk = $.ask("Password", pwd).private()
913+
const login = $.act("Log in")
914+
const signup = $.act("Sign up")
915+
$.surface("main").contains(emailAsk, login, signup)
916+
$.flow("login").startsWith(emailAsk).then(pwdAsk).then(login)
917+
$.flow("register").startsWith(pwdAsk).then(signup)
918+
})
919+
const inspected = inspectScreen(screenDef)
920+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
921+
expect(diags).toHaveLength(2)
922+
expect(diags[0]?.nodeId).toBe("ask_password")
923+
expect(diags[0]?.flow?.flowNodeId).toBe("flow_login")
924+
expect(diags[1]?.nodeId).toBe("ask_password")
925+
expect(diags[1]?.flow?.flowNodeId).toBe("flow_register")
926+
})
927+
928+
it("includes nodeId and semanticNodeId for the unsurfaced step", () => {
929+
const screenDef = screen("WithSemanticStep", $ => {
930+
const email = $.state.text("email")
931+
const emailAsk = $.ask("Email", email).private()
932+
const login = $.act("Log in")
933+
$.surface("main").contains(login)
934+
$.flow("login").startsWith(emailAsk).then(login)
935+
})
936+
const inspected = inspectScreen(screenDef)
937+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
938+
expect(diags).toHaveLength(1)
939+
expect(diags[0]?.nodeId).toBe("ask_email")
940+
expect(diags[0]?.semanticNodeId).toBe("ask:email")
941+
})
942+
943+
it("includes nested flow.flowNodeId and flow.flowSemanticNodeId", () => {
944+
const screenDef = screen("NestedFlowMeta", $ => {
945+
const email = $.state.text("email")
946+
const emailAsk = $.ask("Email", email).private()
947+
const login = $.act("Log in")
948+
$.surface("main").contains(login)
949+
$.flow("login").startsWith(emailAsk).then(login)
950+
})
951+
const inspected = inspectScreen(screenDef)
952+
const diags = inspected.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
953+
expect(diags).toHaveLength(1)
954+
expect(diags[0]?.flow?.flowNodeId).toBe("flow_login")
955+
expect(diags[0]?.flow?.flowSemanticNodeId).toBe("flow:login")
956+
})
957+
958+
it("coexists with surfaced-node-not-in-any-flow without breaking determinism", () => {
959+
const screenDef = screen("CoexistFlow", $ => {
960+
const email = $.state.text("email")
961+
const pwd = $.state.text("password")
962+
const emailAsk = $.ask("Email", email).private()
963+
const pwdAsk = $.ask("Password", pwd).private()
964+
const login = $.act("Log in").primary()
965+
const signup = $.act("Sign up").primary()
966+
$.surface("main").contains(emailAsk, login, signup)
967+
$.flow("login").startsWith(emailAsk).then(pwdAsk).then(login)
968+
})
969+
const first = inspectScreen(screenDef)
970+
const second = inspectScreen(screenDef)
971+
expect(first.diagnostics.map(d => d.code)).toEqual(second.diagnostics.map(d => d.code))
972+
const codes = first.diagnostics.map(d => d.code)
973+
expect(codes).toContain("flow-step-not-surfaced")
974+
expect(codes).toContain("surfaced-node-not-in-any-flow")
975+
const flowDiags = first.diagnostics.filter(d => d.code === "flow-step-not-surfaced")
976+
expect(flowDiags).toHaveLength(1)
977+
expect(flowDiags[0]?.nodeId).toBe("ask_password")
978+
const reachDiags = first.diagnostics.filter(d => d.code === "surfaced-node-not-in-any-flow")
979+
expect(reachDiags).toHaveLength(1)
980+
expect(reachDiags[0]?.nodeId).toBe("act_sign_up")
981+
})
982+
})
834983
})
835984

836985
describe("resource", () => {

packages/core/src/graph.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,23 @@ function computeDiagnostics<TServices extends object = DefaultScreenServices>(
172172
}
173173
}
174174

175+
// flow-step-not-surfaced: step referenced in a flow but not in any surface
176+
for (const flow of screenDef.flows) {
177+
for (const step of flow.steps) {
178+
if (!surfacedNodeIds.has(step.node.id)) {
179+
diagnostics.push({
180+
severity: "warning",
181+
code: "flow-step-not-surfaced",
182+
message: `"${step.node.label}" is a flow step but not included in any surface.`,
183+
nodeId: step.node.id,
184+
flow: {
185+
flowNodeId: flow.id,
186+
},
187+
})
188+
}
189+
}
190+
}
191+
175192
for (const ask of screenDef.asks) {
176193
if (surfacedNodeIds.has(ask.id) && !flowNodeIds.has(ask.id)) {
177194
diagnostics.push({
@@ -218,9 +235,17 @@ export function inspectScreen<TServices extends object = DefaultScreenServices>(
218235
idToSemantic.set(a.id, actIds(a.label))
219236
}
220237

238+
const idToFlowSemantic = new Map<string, string>()
239+
for (const f of screenDef.flows) {
240+
idToFlowSemantic.set(f.id, flowIds(f.name))
241+
}
242+
221243
const augmentedDiagnostics: GraphDiagnostic[] = diagnostics.map(d => ({
222244
...d,
223245
semanticNodeId: d.nodeId ? idToSemantic.get(d.nodeId) : undefined,
246+
flow: d.flow
247+
? { ...d.flow, flowSemanticNodeId: idToFlowSemantic.get(d.flow.flowNodeId) }
248+
: undefined,
224249
}))
225250

226251
return {
@@ -249,7 +274,7 @@ export function inspectScreen<TServices extends object = DefaultScreenServices>(
249274
})),
250275
flows: screenDef.flows.map(f => ({
251276
id: f.id,
252-
semanticId: flowIds(f.name),
277+
semanticId: idToFlowSemantic.get(f.id)!,
253278
name: f.name,
254279
stepCount: f.steps.length,
255280
})),

0 commit comments

Comments
 (0)