Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`/`<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.
**Accept:** fixture `b9-cross-app-hops` green (OAuth redirect, Stripe `window.open`, mailto link, `/auth/callback` entry).

---

## Phase 4 — Matching engine
Expand Down
3 changes: 3 additions & 0 deletions eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function CallbackPage() {
return <p>Completing sign-in…</p>;
}
11 changes: 11 additions & 0 deletions eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function CheckoutPage() {
// Payment gateway — an outbound hop to Stripe.
const pay = () => window.open("https://checkout.stripe.com/pay");

return (
<div>
<h1>Checkout</h1>
<button onClick={pay}>Pay now</button>
</div>
);
}
13 changes: 13 additions & 0 deletions eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Sign in</h1>
<button onClick={loginWithGoogle}>Continue with Google</button>
<a href="mailto:support@example.com">Contact support</a>
</div>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/b9-cross-app-hops/app/routes.tsx
Original file line number Diff line number Diff line change
@@ -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: <LoginPage /> },
{ path: "/checkout", element: <CheckoutPage /> },
{ path: "/auth/callback", element: <CallbackPage /> },
]);
22 changes: 22 additions & 0 deletions eval/fixtures/b9-cross-app-hops/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
41 changes: 41 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href>
// 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, {
Expand Down
17 changes: 16 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
};
}

Expand All @@ -145,7 +159,8 @@ export interface CheckResult {
| "routes"
| "effects"
| "journeys"
| "conditions";
| "conditions"
| "externals";
status: CheckStatus;
detail?: string;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const STEP_ARROW: Record<string, string> = {
navigate: "→",
fetch: "⇢",
"state-write": "✎",
exit: "⏏",
};

function printJourneyPath(path: JourneyPath): void {
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult<Linea
return ok([{ value: lineage, confidence: confidenceFromScore(0.9), evidence }]);
}

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

/** One node on a user-journey path, with the condition (if any) that gated it. */
export interface JourneyStep {
Expand Down Expand Up @@ -711,14 +711,20 @@ export function journeys(
...(stepCondition ? { condition: stepCondition } : {}),
};
expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited);
} else if (effect.kind === "triggers" || effect.kind === "writes-state") {
} else if (
effect.kind === "triggers" ||
effect.kind === "writes-state" ||
effect.kind === "exits-app"
) {
const target = byId.get(effect.to);
if (target === undefined) continue;
branched = true;
const leaf: JourneyStep =
target.kind === "data-source"
? { kind: "fetch", nodeId: target.id, label: target.endpoint, loc: target.loc }
: { kind: "state-write", nodeId: target.id, label: target.name, loc: target.loc };
: target.kind === "external"
? { kind: "exit", nodeId: target.id, label: target.host, loc: target.loc }
: { kind: "state-write", nodeId: target.id, label: target.name, loc: target.loc };
if (paths.length >= maxPaths) {
truncated = true;
return;
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(). */
Expand Down Expand Up @@ -251,14 +252,28 @@ 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
| HookNode
| DataSourceNode
| StateNode
| EventNode
| RouteNode;
| RouteNode
| ExternalNode;

export type EdgeKind =
| "renders" // component|instance -> instance (definition-level until Phase 2.1)
Expand All @@ -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). */
Expand Down
29 changes: 29 additions & 0 deletions packages/parser-react/src/effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href=\"mailto:…\"> 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") }));

Expand Down
19 changes: 19 additions & 0 deletions packages/parser-react/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading