diff --git a/TRACKER.md b/TRACKER.md
index a6426e9..eeee9a7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -214,6 +214,11 @@ The heart of the project. C1 and B1 live here.
**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.
**Accept:** fixture `g5-feature-flag` green: flag-gated UI's journey step carries the flag name. **Gate 3 passes.**
+### [x] 3.6 Cross-app hops (added — was a catalog gap)
+**Failure modes:** B9
+**Build:** `ExternalNode` + `exits-app` / `enters-at` edges. Navigate/`window.open`/`window.location.assign`/``/`` 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.
+**Accept:** fixture `b9-cross-app-hops` green (OAuth redirect, Stripe `window.open`, mailto link, `/auth/callback` entry).
+
---
## Phase 4 — Matching engine
diff --git a/eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx b/eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx
new file mode 100644
index 0000000..aee0799
--- /dev/null
+++ b/eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx
@@ -0,0 +1,3 @@
+export function CallbackPage() {
+ return Completing sign-in…
;
+}
diff --git a/eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx b/eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx
new file mode 100644
index 0000000..33ade82
--- /dev/null
+++ b/eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx
@@ -0,0 +1,11 @@
+export function CheckoutPage() {
+ // Payment gateway — an outbound hop to Stripe.
+ const pay = () => window.open("https://checkout.stripe.com/pay");
+
+ return (
+
+
Checkout
+
+
+ );
+}
diff --git a/eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx b/eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx
new file mode 100644
index 0000000..277aa53
--- /dev/null
+++ b/eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx
@@ -0,0 +1,13 @@
+export function LoginPage() {
+ // OAuth redirect — the journey leaves the app for the provider.
+ const loginWithGoogle = () =>
+ window.location.assign("https://accounts.google.com/o/oauth2/auth");
+
+ return (
+
+ );
+}
diff --git a/eval/fixtures/b9-cross-app-hops/app/routes.tsx b/eval/fixtures/b9-cross-app-hops/app/routes.tsx
new file mode 100644
index 0000000..281c7ec
--- /dev/null
+++ b/eval/fixtures/b9-cross-app-hops/app/routes.tsx
@@ -0,0 +1,11 @@
+import { createBrowserRouter } from "react-router-dom";
+
+import { CallbackPage } from "./CallbackPage";
+import { CheckoutPage } from "./CheckoutPage";
+import { LoginPage } from "./LoginPage";
+
+export const router = createBrowserRouter([
+ { path: "/login", element: },
+ { path: "/checkout", element: },
+ { path: "/auth/callback", element: },
+]);
diff --git a/eval/fixtures/b9-cross-app-hops/golden.json b/eval/fixtures/b9-cross-app-hops/golden.json
new file mode 100644
index 0000000..5fcf214
--- /dev/null
+++ b/eval/fixtures/b9-cross-app-hops/golden.json
@@ -0,0 +1,22 @@
+{
+ "failureMode": "B9",
+ "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.",
+ "expect": {
+ "components": [
+ { "name": "LoginPage", "instances": 0 },
+ { "name": "CheckoutPage", "instances": 0 },
+ { "name": "CallbackPage", "instances": 0 }
+ ],
+ "routes": [
+ { "path": "/login", "component": "LoginPage" },
+ { "path": "/checkout", "component": "CheckoutPage" },
+ { "path": "/auth/callback", "component": "CallbackPage" }
+ ],
+ "externals": [
+ { "kind": "exits", "component": "LoginPage", "host": "accounts.google.com" },
+ { "kind": "exits", "component": "LoginPage", "host": "mailto" },
+ { "kind": "exits", "component": "CheckoutPage", "host": "checkout.stripe.com" },
+ { "kind": "enters", "route": "/auth/callback", "host": "inbound" }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index bd0e202..93054f7 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -222,6 +222,47 @@ export function runChecks(
);
}
+ for (const expected of golden.expect.externals ?? []) {
+ const id = `external:${expected.kind}:${expected.component ?? expected.route}~${expected.host}`;
+ const node = (nid: string) => graph.nodes.find((n) => n.id === nid);
+ let matched = false;
+ if (expected.kind === "exits") {
+ const owner = graph.nodes.find((n) => n.kind === "component" && n.name === expected.component);
+ matched = graph.edges.some((e) => {
+ if (e.kind !== "exits-app") return false;
+ const target = node(e.to);
+ if (target?.kind !== "external" || target.host !== expected.host || owner === undefined) {
+ return false;
+ }
+ if (e.from === owner.id) return true; // direct
+ // otherwise an event handled by the owner component
+ return (
+ node(e.from)?.kind === "event" &&
+ graph.edges.some((h) => h.kind === "handles" && h.from === owner.id && h.to === e.from)
+ );
+ });
+ } else {
+ matched = graph.edges.some((e) => {
+ if (e.kind !== "enters-at") return false;
+ const route = node(e.to);
+ const from = node(e.from);
+ return (
+ route?.kind === "route" &&
+ route.path === expected.route &&
+ from?.kind === "external" &&
+ from.host === expected.host
+ );
+ });
+ }
+ finalize(
+ "externals",
+ id,
+ matched,
+ expected.expectedFail,
+ matched ? undefined : `no ${expected.kind}-app edge for ${expected.host}`,
+ );
+ }
+
for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
const result = matchComponents(graph, {
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index 22ef187..8b050ca 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -104,6 +104,18 @@ export interface GoldenCondition {
expectedFail?: string;
}
+/** A cross-app hop (step B9): an exits-app or enters-at edge. */
+export interface GoldenExternal {
+ kind: "exits" | "enters";
+ /** exits: the component whose link/handler leaves the app. */
+ component?: string;
+ /** enters: the inbound entry route path. */
+ route?: string;
+ /** External host/scheme the edge connects to (accounts.google.com, mailto, inbound). */
+ host: string;
+ expectedFail?: string;
+}
+
export interface Golden {
failureMode: string;
note?: string;
@@ -129,6 +141,8 @@ export interface Golden {
journeys?: GoldenJourney[];
/** Flag/role conditions on renders/handles edges (step 3.5). */
conditions?: GoldenCondition[];
+ /** Cross-app hops (B9): exits-app / enters-at edges. */
+ externals?: GoldenExternal[];
};
}
@@ -145,7 +159,8 @@ export interface CheckResult {
| "routes"
| "effects"
| "journeys"
- | "conditions";
+ | "conditions"
+ | "externals";
status: CheckStatus;
detail?: string;
}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index ebf31d6..25a7047 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -195,6 +195,7 @@ const STEP_ARROW: Record = {
navigate: "→",
fetch: "⇢",
"state-write": "✎",
+ exit: "⏏",
};
function printJourneyPath(path: JourneyPath): void {
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index c474e2d..32bd060 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -528,7 +528,7 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult= maxPaths) {
truncated = true;
return;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index a6ab9d1..18670eb 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -28,7 +28,8 @@ export type NodeKind =
| "data-source"
| "state"
| "event"
- | "route";
+ | "route"
+ | "external";
export interface BaseNode {
/** Stable id, unique within a graph. See nodeId()/instanceId(). */
@@ -251,6 +252,19 @@ export interface RouteNode extends BaseNode {
guards: string[];
}
+/**
+ * A destination outside this codebase (TRACKER failure mode B9): an OAuth
+ * provider, a payment gateway, a `mailto:`/`tel:` link, or the inbound side of
+ * a deep-link/OAuth-callback route. Journeys that reach one leave the app.
+ */
+export interface ExternalNode extends BaseNode {
+ kind: "external";
+ /** Destination as written — a URL, a `mailto:`/`tel:` scheme, or "inbound". */
+ url: string;
+ /** Host or scheme used to group destinations (accounts.google.com, mailto). */
+ host: string;
+}
+
export type LineageNode =
| ComponentNode
| InstanceNode
@@ -258,7 +272,8 @@ export type LineageNode =
| DataSourceNode
| StateNode
| EventNode
- | RouteNode;
+ | RouteNode
+ | ExternalNode;
export type EdgeKind =
| "renders" // component|instance -> instance (definition-level until Phase 2.1)
@@ -271,6 +286,8 @@ export type EdgeKind =
| "handles" // component -> event
| "triggers" // event -> data-source (resolved handler body issues a fetch; Phase 3.2)
| "navigates-to" // event -> route (handler calls navigate/router.push; Phase 3.2, B3)
+ | "exits-app" // event|component -> external (navigate/link to an outside URL; B9)
+ | "enters-at" // external -> route (a deep-link / OAuth-callback entry point; B9)
| "routes-to"; // route -> page component definition (its instances form the page tree)
/** A statically-detected condition guarding an edge (feature flag, role, branch). */
diff --git a/packages/parser-react/src/effects.test.ts b/packages/parser-react/src/effects.test.ts
index f976ac7..174bcfe 100644
--- a/packages/parser-react/src/effects.test.ts
+++ b/packages/parser-react/src/effects.test.ts
@@ -116,6 +116,35 @@ describe("form & non-JSX events (TRACKER 3.4, B7/B8)", () => {
});
});
+describe("cross-app hops (b9 fixture, TRACKER B9)", () => {
+ const b9 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b9-cross-app-hops/app") }));
+ const external = (host: string) =>
+ b9.nodes.find((n) => n.kind === "external" && n.name === host);
+
+ it("a window.open / location.assign to an external URL exits the app", () => {
+ for (const host of ["accounts.google.com", "checkout.stripe.com"]) {
+ const ext = external(host);
+ expect(ext).toBeDefined();
+ expect(b9.edges.some((e) => e.kind === "exits-app" && e.to === ext?.id)).toBe(true);
+ }
+ });
+
+ it("an is an exits-app edge from the component", () => {
+ const mailto = external("mailto");
+ const login = b9.nodes.find((n) => n.kind === "component" && n.name === "LoginPage");
+ expect(
+ b9.edges.some((e) => e.kind === "exits-app" && e.from === login?.id && e.to === mailto?.id),
+ ).toBe(true);
+ });
+
+ it("an /auth/callback route is an inbound entry point (enters-at)", () => {
+ const callbackRoute = b9.nodes.find((n) => n.kind === "route" && n.path === "/auth/callback");
+ expect(
+ b9.edges.some((e) => e.kind === "enters-at" && e.to === callbackRoute?.id),
+ ).toBe(true);
+ });
+});
+
describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => {
const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") }));
diff --git a/packages/parser-react/src/routes.ts b/packages/parser-react/src/routes.ts
index c4e5aec..aa5a321 100644
--- a/packages/parser-react/src/routes.ts
+++ b/packages/parser-react/src/routes.ts
@@ -48,6 +48,9 @@ import {
/** Auth/role wrapper components recorded as guards rather than layouts. */
const GUARD_NAME = /^(Require|Protected?|Private)|Guard$/;
+/** Route paths that are deep-link / OAuth-callback entry points from outside the app (B9). */
+const EXTERNAL_ENTRY = /callback|oauth|\bsso\b|\/auth\/|\/redirect|\/return\b/i;
+
const ROUTER_FACTORIES = new Set([
"createBrowserRouter",
"createHashRouter",
@@ -140,6 +143,22 @@ class RouteAdapter {
};
this.nodes.set(id, route);
if (page !== null) this.addEdge({ from: id, to: page, kind: "routes-to" });
+
+ // Deep-link / OAuth-callback routes are entry points from outside the app (B9).
+ if (EXTERNAL_ENTRY.test(routePath)) {
+ const inbound = "external:inbound";
+ if (!this.nodes.has(inbound)) {
+ this.nodes.set(inbound, {
+ id: inbound,
+ kind: "external",
+ name: "inbound",
+ loc,
+ url: "inbound",
+ host: "inbound",
+ });
+ }
+ this.addEdge({ from: inbound, to: id, kind: "enters-at" });
+ }
}
/** "/users" + "settings/:id" → "/users/settings/:id"; absolute children win. */
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index 4161809..e0fe31f 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -723,6 +723,15 @@ function extractBodyFacts(
for (const attr of body.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
const attrName = attr.getNameNode().getText();
+ // / — a link that leaves the app (B9).
+ if (attrName === "href" || attrName === "to") {
+ const url = jsxAttrString(attr);
+ if (url !== null && isExternalUrl(url)) {
+ const ext = ensureExternalNode(nodes, url, locOf(attr, file));
+ addEdge({ from: ownerId, to: ext, kind: "exits-app", via: url });
+ }
+ continue;
+ }
if (!/^on[A-Z]/.test(attrName)) continue;
const init = attr.getInitializer();
if (init === undefined || !Node.isJsxExpression(init)) {
@@ -760,6 +769,19 @@ function jsxTagName(attr: Node): string {
return "";
}
+/** A JSX attribute's statically-known string value (href="…" / to={CONST}), or null. */
+function jsxAttrString(attr: Node): string | null {
+ if (!Node.isJsxAttribute(attr)) return null;
+ const init = attr.getInitializer();
+ if (init === undefined) return null;
+ if (Node.isStringLiteral(init)) return init.getLiteralValue();
+ if (Node.isJsxExpression(init)) {
+ const expr = init.getExpression();
+ if (expr !== undefined) return resolveStringValue(expr, 0);
+ }
+ return null;
+}
+
/** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */
function selectorSliceId(call: CallExpression, stores: StoreRegistry): string | undefined {
const selector = call.getArguments()[0];
@@ -1620,9 +1642,35 @@ type Effect =
| { kind: "triggers"; to: string }
| { kind: "writes-state"; to: string }
| { kind: "navigates-to"; to: string; via: string }
+ | { kind: "exits-app"; to: string; via: string }
/** Internal: navigation to a resolved path with no matching route → flag only. */
| { kind: "nav-unresolved"; to: string };
+/** A URL that leaves this app: absolute/protocol-relative, or a mailto/tel/sms scheme. */
+function isExternalUrl(value: string): boolean {
+ return /^(?:https?:)?\/\//.test(value) || /^(mailto|tel|sms):/i.test(value);
+}
+
+/** Host (accounts.google.com) or scheme (mailto) used to group external destinations. */
+function externalHost(url: string): string {
+ const scheme = /^(mailto|tel|sms):/i.exec(url);
+ if (scheme?.[1] !== undefined) return scheme[1].toLowerCase();
+ const host = /^(?:https?:)?\/\/([^/?#]+)/.exec(url);
+ return host?.[1] ?? url;
+}
+
+/** Reuse or create the ExternalNode for a destination (deduped by host). */
+function ensureExternalNode(
+ nodes: Map,
+ url: string,
+ loc: SourceLocation,
+): string {
+ const host = externalHost(url);
+ const id = `external:${host}`;
+ if (!nodes.has(id)) nodes.set(id, { id, kind: "external", name: host, loc, url, host });
+ return id;
+}
+
function resolveHandlerChains(
pendingInstances: PendingInstance[],
instanceIds: Array,
@@ -1703,6 +1751,18 @@ function resolveHandlerChains(
return resolved.resolved === "none" ? null : resolved;
};
+ /** window.open(url) / location.assign(url) / location.replace(url) → external URL, or null. */
+ const externalNavTarget = (call: CallExpression, calleeExpr: Node): ResolvedEndpoint | null => {
+ if (!Node.isPropertyAccessExpression(calleeExpr)) return null;
+ const method = calleeExpr.getName();
+ const receiver = calleeExpr.getExpression().getText();
+ const isOpen = method === "open" && /(^|\.)(window|globalThis)$/.test(receiver);
+ const isLocation = (method === "assign" || method === "replace") && /(^|\.)location$/.test(receiver);
+ if (!isOpen && !isLocation) return null;
+ const resolved = resolveEndpoint(call.getArguments()[0], []);
+ return resolved.resolved !== "none" && isExternalUrl(resolved.endpoint) ? resolved : null;
+ };
+
/** dispatch(thunk()) / dispatch(action()) → the slice StateNode ids it writes. */
const dispatchTargets = (call: CallExpression, calleeExpr: Node): string[] => {
const callee = calleeExpr.getText();
@@ -1759,8 +1819,14 @@ function resolveHandlerChains(
if (!Node.isCallExpression(call)) continue;
const calleeExpr = call.getExpression();
- const navTarget = navigationTarget(call, calleeExpr);
+ const navTarget = navigationTarget(call, calleeExpr) ?? externalNavTarget(call, calleeExpr);
if (navTarget !== null) {
+ if (isExternalUrl(navTarget.endpoint)) {
+ // OAuth redirect, payment gateway, mailto: — the journey leaves the app (B9).
+ const ext = ensureExternalNode(nodes, navTarget.endpoint, locOf(call, fileOf(call)));
+ pushEffect(effects, { kind: "exits-app", to: ext, via: navTarget.endpoint });
+ continue;
+ }
const routeId = matchRoute(navTarget.endpoint);
pushEffect(
effects,
@@ -1961,7 +2027,9 @@ function resolveHandlerChains(
from: eventId,
to: effect.to,
kind: effect.kind,
- ...(effect.kind === "navigates-to" ? { via: effect.via } : {}),
+ ...(effect.kind === "navigates-to" || effect.kind === "exits-app"
+ ? { via: effect.via }
+ : {}),
});
}
const flags: string[] = [];
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index 5ca662b..f21d229 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -90,6 +90,9 @@
},
{
"$ref": "#/definitions/RouteNode"
+ },
+ {
+ "$ref": "#/definitions/ExternalNode"
}
]
},
@@ -636,6 +639,50 @@
],
"description": "Which routing system declared a route."
},
+ "ExternalNode": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Stable id, unique within a graph. See nodeId()/instanceId()."
+ },
+ "kind": {
+ "type": "string",
+ "const": "external"
+ },
+ "name": {
+ "type": "string"
+ },
+ "loc": {
+ "$ref": "#/definitions/SourceLocation"
+ },
+ "flags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean."
+ },
+ "url": {
+ "type": "string",
+ "description": "Destination as written — a URL, a `mailto:`/`tel:` scheme, or \"inbound\"."
+ },
+ "host": {
+ "type": "string",
+ "description": "Host or scheme used to group destinations (accounts.google.com, mailto)."
+ }
+ },
+ "required": [
+ "host",
+ "id",
+ "kind",
+ "loc",
+ "name",
+ "url"
+ ],
+ "additionalProperties": false,
+ "description": "A destination outside this codebase (TRACKER failure mode B9): an OAuth provider, a payment gateway, a `mailto:`/`tel:` link, or the inbound side of a deep-link/OAuth-callback route. Journeys that reach one leave the app."
+ },
"LineageEdge": {
"type": "object",
"properties": {
@@ -676,6 +723,8 @@
"handles",
"triggers",
"navigates-to",
+ "exits-app",
+ "enters-at",
"routes-to"
]
},