Skip to content

Commit 0b97b22

Browse files
Merge pull request #33 from officialCodeWork/build/phase-3/step-b9-cross-app
feat(journeys): cross-app hops — exits-app / enters-at (B9)
2 parents 20babbb + 5b1d568 commit 0b97b22

15 files changed

Lines changed: 318 additions & 8 deletions

File tree

TRACKER.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ The heart of the project. C1 and B1 live here.
214214
**Build:** feature-flag detection (configurable call names: `useFlag`, `useFeature`, `isEnabled`) and role checks in render branches → `EdgeCondition{kind:"flag"|"role"}` on the enclosed `renders`/`handles` edges.
215215
**Accept:** fixture `g5-feature-flag` green: flag-gated UI's journey step carries the flag name. **Gate 3 passes.**
216216

217+
### [x] 3.6 Cross-app hops (added — was a catalog gap)
218+
**Failure modes:** B9
219+
**Build:** `ExternalNode` + `exits-app` / `enters-at` edges. Navigate/`window.open`/`window.location.assign`/`<a href>`/`<Link to>` to an absolute URL or `mailto:`/`tel:` scheme → `exits-app` (event or component → external, deduped by host). Deep-link/OAuth-callback route paths → `enters-at` from an inbound external node. Journeys reaching an external end with an `exit` step.
220+
**Accept:** fixture `b9-cross-app-hops` green (OAuth redirect, Stripe `window.open`, mailto link, `/auth/callback` entry).
221+
217222
---
218223

219224
## Phase 4 — Matching engine
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function CallbackPage() {
2+
return <p>Completing sign-in…</p>;
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function CheckoutPage() {
2+
// Payment gateway — an outbound hop to Stripe.
3+
const pay = () => window.open("https://checkout.stripe.com/pay");
4+
5+
return (
6+
<div>
7+
<h1>Checkout</h1>
8+
<button onClick={pay}>Pay now</button>
9+
</div>
10+
);
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function LoginPage() {
2+
// OAuth redirect — the journey leaves the app for the provider.
3+
const loginWithGoogle = () =>
4+
window.location.assign("https://accounts.google.com/o/oauth2/auth");
5+
6+
return (
7+
<div>
8+
<h1>Sign in</h1>
9+
<button onClick={loginWithGoogle}>Continue with Google</button>
10+
<a href="mailto:support@example.com">Contact support</a>
11+
</div>
12+
);
13+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createBrowserRouter } from "react-router-dom";
2+
3+
import { CallbackPage } from "./CallbackPage";
4+
import { CheckoutPage } from "./CheckoutPage";
5+
import { LoginPage } from "./LoginPage";
6+
7+
export const router = createBrowserRouter([
8+
{ path: "/login", element: <LoginPage /> },
9+
{ path: "/checkout", element: <CheckoutPage /> },
10+
{ path: "/auth/callback", element: <CallbackPage /> },
11+
]);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"failureMode": "B9",
3+
"note": "Cross-app hops: an OAuth redirect (window.location.assign), a payment gateway (window.open), and a mailto link all leave the app → exits-app edges to external destinations; an /auth/callback route is an inbound entry point → an enters-at edge. Journeys that reach an external end with an 'exit' step.",
4+
"expect": {
5+
"components": [
6+
{ "name": "LoginPage", "instances": 0 },
7+
{ "name": "CheckoutPage", "instances": 0 },
8+
{ "name": "CallbackPage", "instances": 0 }
9+
],
10+
"routes": [
11+
{ "path": "/login", "component": "LoginPage" },
12+
{ "path": "/checkout", "component": "CheckoutPage" },
13+
{ "path": "/auth/callback", "component": "CallbackPage" }
14+
],
15+
"externals": [
16+
{ "kind": "exits", "component": "LoginPage", "host": "accounts.google.com" },
17+
{ "kind": "exits", "component": "LoginPage", "host": "mailto" },
18+
{ "kind": "exits", "component": "CheckoutPage", "host": "checkout.stripe.com" },
19+
{ "kind": "enters", "route": "/auth/callback", "host": "inbound" }
20+
]
21+
}
22+
}

eval/src/checks.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,47 @@ export function runChecks(
222222
);
223223
}
224224

225+
for (const expected of golden.expect.externals ?? []) {
226+
const id = `external:${expected.kind}:${expected.component ?? expected.route}~${expected.host}`;
227+
const node = (nid: string) => graph.nodes.find((n) => n.id === nid);
228+
let matched = false;
229+
if (expected.kind === "exits") {
230+
const owner = graph.nodes.find((n) => n.kind === "component" && n.name === expected.component);
231+
matched = graph.edges.some((e) => {
232+
if (e.kind !== "exits-app") return false;
233+
const target = node(e.to);
234+
if (target?.kind !== "external" || target.host !== expected.host || owner === undefined) {
235+
return false;
236+
}
237+
if (e.from === owner.id) return true; // direct <a href>
238+
// otherwise an event handled by the owner component
239+
return (
240+
node(e.from)?.kind === "event" &&
241+
graph.edges.some((h) => h.kind === "handles" && h.from === owner.id && h.to === e.from)
242+
);
243+
});
244+
} else {
245+
matched = graph.edges.some((e) => {
246+
if (e.kind !== "enters-at") return false;
247+
const route = node(e.to);
248+
const from = node(e.from);
249+
return (
250+
route?.kind === "route" &&
251+
route.path === expected.route &&
252+
from?.kind === "external" &&
253+
from.host === expected.host
254+
);
255+
});
256+
}
257+
finalize(
258+
"externals",
259+
id,
260+
matched,
261+
expected.expectedFail,
262+
matched ? undefined : `no ${expected.kind}-app edge for ${expected.host}`,
263+
);
264+
}
265+
225266
for (const query of golden.expect.queries ?? []) {
226267
const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
227268
const result = matchComponents(graph, {

eval/src/golden.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ export interface GoldenCondition {
104104
expectedFail?: string;
105105
}
106106

107+
/** A cross-app hop (step B9): an exits-app or enters-at edge. */
108+
export interface GoldenExternal {
109+
kind: "exits" | "enters";
110+
/** exits: the component whose link/handler leaves the app. */
111+
component?: string;
112+
/** enters: the inbound entry route path. */
113+
route?: string;
114+
/** External host/scheme the edge connects to (accounts.google.com, mailto, inbound). */
115+
host: string;
116+
expectedFail?: string;
117+
}
118+
107119
export interface Golden {
108120
failureMode: string;
109121
note?: string;
@@ -129,6 +141,8 @@ export interface Golden {
129141
journeys?: GoldenJourney[];
130142
/** Flag/role conditions on renders/handles edges (step 3.5). */
131143
conditions?: GoldenCondition[];
144+
/** Cross-app hops (B9): exits-app / enters-at edges. */
145+
externals?: GoldenExternal[];
132146
};
133147
}
134148

@@ -145,7 +159,8 @@ export interface CheckResult {
145159
| "routes"
146160
| "effects"
147161
| "journeys"
148-
| "conditions";
162+
| "conditions"
163+
| "externals";
149164
status: CheckStatus;
150165
detail?: string;
151166
}

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const STEP_ARROW: Record<string, string> = {
195195
navigate: "→",
196196
fetch: "⇢",
197197
"state-write": "✎",
198+
exit: "⏏",
198199
};
199200

200201
function printJourneyPath(path: JourneyPath): void {

packages/core/src/query.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult<Linea
528528
return ok([{ value: lineage, confidence: confidenceFromScore(0.9), evidence }]);
529529
}
530530

531-
export type JourneyStepKind = "page" | "event" | "navigate" | "fetch" | "state-write";
531+
export type JourneyStepKind = "page" | "event" | "navigate" | "fetch" | "state-write" | "exit";
532532

533533
/** One node on a user-journey path, with the condition (if any) that gated it. */
534534
export interface JourneyStep {
@@ -711,14 +711,20 @@ export function journeys(
711711
...(stepCondition ? { condition: stepCondition } : {}),
712712
};
713713
expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited);
714-
} else if (effect.kind === "triggers" || effect.kind === "writes-state") {
714+
} else if (
715+
effect.kind === "triggers" ||
716+
effect.kind === "writes-state" ||
717+
effect.kind === "exits-app"
718+
) {
715719
const target = byId.get(effect.to);
716720
if (target === undefined) continue;
717721
branched = true;
718722
const leaf: JourneyStep =
719723
target.kind === "data-source"
720724
? { kind: "fetch", nodeId: target.id, label: target.endpoint, loc: target.loc }
721-
: { kind: "state-write", nodeId: target.id, label: target.name, loc: target.loc };
725+
: target.kind === "external"
726+
? { kind: "exit", nodeId: target.id, label: target.host, loc: target.loc }
727+
: { kind: "state-write", nodeId: target.id, label: target.name, loc: target.loc };
722728
if (paths.length >= maxPaths) {
723729
truncated = true;
724730
return;

0 commit comments

Comments
 (0)