From 5b1d568f507a30e4a3d7b896ee496cf1ad985521 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 03:11:19 +0530 Subject: [PATCH] =?UTF-8?q?feat(journeys):=20cross-app=20hops=20=E2=80=94?= =?UTF-8?q?=20exits-app=20/=20enters-at=20edges=20(B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the B9 gap (cross-app hops were catalogued under Phase 3 but never built): - New ExternalNode + `exits-app` / `enters-at` edge kinds. - exits-app: navigate / window.open / window.location.assign|replace, and `` / ``, to an absolute/protocol-relative URL or a mailto:/tel:/sms: scheme → an edge to an external destination (deduped by host). OAuth redirects, payment gateways, and email links all leave the app. - enters-at: deep-link / OAuth-callback route paths (/auth/…, /callback, oauth, sso, …) → an edge from an inbound external node. - journeys() renders app-exits as a terminal `exit` step; CLI glyph ⏏. Schema regenerated (external node kind + two edge kinds). New `externals` eval check + fixture b9-cross-app-hops (Google OAuth, Stripe window.open, mailto, /auth/callback). 41 core + 3 vision + 105 parser tests pass; eval green. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 5 ++ .../b9-cross-app-hops/app/CallbackPage.tsx | 3 + .../b9-cross-app-hops/app/CheckoutPage.tsx | 11 +++ .../b9-cross-app-hops/app/LoginPage.tsx | 13 ++++ .../fixtures/b9-cross-app-hops/app/routes.tsx | 11 +++ eval/fixtures/b9-cross-app-hops/golden.json | 22 ++++++ eval/src/checks.ts | 41 +++++++++++ eval/src/golden.ts | 17 ++++- packages/cli/src/index.ts | 1 + packages/core/src/query.ts | 12 +++- packages/core/src/types.ts | 21 +++++- packages/parser-react/src/effects.test.ts | 29 ++++++++ packages/parser-react/src/routes.ts | 19 +++++ packages/parser-react/src/scan.ts | 72 ++++++++++++++++++- schemas/lineage-graph.schema.json | 49 +++++++++++++ 15 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx create mode 100644 eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx create mode 100644 eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx create mode 100644 eval/fixtures/b9-cross-app-hops/app/routes.tsx create mode 100644 eval/fixtures/b9-cross-app-hops/golden.json 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 ( +
+

Sign in

+ +
Contact support +
+ ); +} 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" ] },