diff --git a/TRACKER.md b/TRACKER.md
index 3149476..0899814 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -4,10 +4,10 @@
## Status
-- **Current phase:** 0 — Foundations
-- **Next step:** 1.1 — Endpoint resolution (constants, templates, patterns)
-- **Done:** 0.1, 0.2, 0.3, 0.4
-- **Gates passed:** none yet (Gate 0 completes with 0.4)
+- **Current phase:** 1 — Robust extraction
+- **Next step:** 1.2 — API-client wrapper adapter
+- **Done:** 0.1–0.4, 1.1
+- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)
## What CodeRadar is
@@ -88,7 +88,7 @@ The v0.1 schema is definition-only, which is *wrong* for C1 — fix before build
Make the parser survive real code instead of demo code. All work is within-file or
follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
-### [ ] 1.1 Endpoint resolution — constants, templates, patterns
+### [x] 1.1 Endpoint resolution — constants, templates, patterns
**Failure modes:** C2 (constants half), C3
**Build:** in `parser-react`:
- Resolve identifier arguments to fetch/axios through imports to their declarations (constant folding: string literals, `const` object members like `ENDPOINTS.USERS`, simple concatenation).
diff --git a/eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx b/eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx
new file mode 100644
index 0000000..d885add
--- /dev/null
+++ b/eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx
@@ -0,0 +1,15 @@
+import { useEffect, useState } from "react";
+
+import { HEALTH_URL } from "./api/endpoints";
+
+export function HealthBadge() {
+ const [status, setStatus] = useState("unknown");
+
+ useEffect(() => {
+ fetch(HEALTH_URL)
+ .then((res) => res.json())
+ .then((body: { status: string }) => setStatus(body.status));
+ }, []);
+
+ return {status};
+}
diff --git a/eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx b/eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx
new file mode 100644
index 0000000..db9a36a
--- /dev/null
+++ b/eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "react";
+
+import { API_PREFIX } from "./api/endpoints";
+
+export function ReportsPanel() {
+ const [reports, setReports] = useState([]);
+
+ useEffect(() => {
+ fetch(API_PREFIX + "/reports")
+ .then((res) => res.json())
+ .then(setReports);
+ }, []);
+
+ return (
+
+ Weekly reports
+
+ {reports.map((r) => (
+ - {r}
+ ))}
+
+
+ );
+}
diff --git a/eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx b/eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx
new file mode 100644
index 0000000..63c6fbb
--- /dev/null
+++ b/eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "react";
+
+import { ENDPOINTS } from "./api/endpoints";
+
+export function UsersPanel() {
+ const [users, setUsers] = useState([]);
+
+ useEffect(() => {
+ fetch(ENDPOINTS.USERS)
+ .then((res) => res.json())
+ .then(setUsers);
+ }, []);
+
+ return (
+
+ User Directory
+
+ {users.map((u) => (
+ - {u}
+ ))}
+
+
+ );
+}
diff --git a/eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts b/eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts
new file mode 100644
index 0000000..93eadb1
--- /dev/null
+++ b/eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts
@@ -0,0 +1,8 @@
+export const ENDPOINTS = {
+ USERS: "/api/users",
+ INVOICES: "/api/invoices",
+};
+
+export const HEALTH_URL = "/api/health";
+
+export const API_PREFIX = "/api";
diff --git a/eval/fixtures/c2-endpoint-constants/golden.json b/eval/fixtures/c2-endpoint-constants/golden.json
new file mode 100644
index 0000000..bfe209a
--- /dev/null
+++ b/eval/fixtures/c2-endpoint-constants/golden.json
@@ -0,0 +1,27 @@
+{
+ "failureMode": "C2",
+ "note": "Endpoints hidden behind constants: an object member (ENDPOINTS.USERS), a plain named constant (HEALTH_URL), and a concatenation (API_PREFIX + '/reports') — all declared in a different file than the fetch. Constant folding must recover the literal endpoint.",
+ "expect": {
+ "components": [
+ { "name": "UsersPanel", "instances": 0 },
+ { "name": "HealthBadge", "instances": 0 },
+ { "name": "ReportsPanel", "instances": 0 }
+ ],
+ "attributions": [
+ { "component": "UsersPanel", "endpoints": ["/api/users"] },
+ { "component": "HealthBadge", "endpoints": ["/api/health"] },
+ { "component": "ReportsPanel", "endpoints": ["/api/reports"] }
+ ],
+ "forbidden": [
+ {
+ "component": "UsersPanel",
+ "endpoint": "",
+ "note": "constant-hidden endpoint reported as dynamic means folding regressed"
+ }
+ ],
+ "queries": [
+ { "terms": ["User Directory"], "status": "ok", "top": "UsersPanel" },
+ { "terms": ["Weekly reports"], "status": "ok", "top": "ReportsPanel" }
+ ]
+ }
+}
diff --git a/eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx b/eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx
new file mode 100644
index 0000000..52b3ebb
--- /dev/null
+++ b/eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx
@@ -0,0 +1,27 @@
+import { useEffect, useState } from "react";
+
+export function OrderDetail({ orderId, entity }: { orderId: string; entity: string }) {
+ const [order, setOrder] = useState | null>(null);
+ const [related, setRelated] = useState([]);
+
+ useEffect(() => {
+ fetch(`/api/orders/${orderId}`)
+ .then((res) => res.json())
+ .then(setOrder);
+ fetch(`/api/${entity}/list`)
+ .then((res) => res.json())
+ .then(setRelated);
+ }, [orderId, entity]);
+
+ return (
+
+ Order detail
+ {order?.status}
+
+ {related.map((r) => (
+ - {r}
+ ))}
+
+
+ );
+}
diff --git a/eval/fixtures/c3-dynamic-endpoints/golden.json b/eval/fixtures/c3-dynamic-endpoints/golden.json
new file mode 100644
index 0000000..857ec08
--- /dev/null
+++ b/eval/fixtures/c3-dynamic-endpoints/golden.json
@@ -0,0 +1,21 @@
+{
+ "failureMode": "C3",
+ "note": "Runtime-valued endpoints: `/api/orders/${orderId}` must resolve to the pattern /api/orders/:orderId (partial), and `/api/${entity}/list` — where even the resource segment is dynamic — to /api/:entity/list. The shape is preserved, the unknowns are flagged, and nothing collapses to .",
+ "expect": {
+ "components": [{ "name": "OrderDetail", "instances": 0 }],
+ "attributions": [
+ {
+ "component": "OrderDetail",
+ "endpoints": ["/api/orders/:orderId", "/api/:entity/list"]
+ }
+ ],
+ "forbidden": [
+ {
+ "component": "OrderDetail",
+ "endpoint": "",
+ "note": "template endpoints with known shape must not collapse to dynamic"
+ }
+ ],
+ "queries": [{ "terms": ["Order detail"], "status": "ok", "top": "OrderDetail" }]
+ }
+}
diff --git a/eval/fixtures/demo-app/golden.json b/eval/fixtures/demo-app/golden.json
index c027f35..48c24c2 100644
--- a/eval/fixtures/demo-app/golden.json
+++ b/eval/fixtures/demo-app/golden.json
@@ -10,11 +10,11 @@
"attributions": [
{
"component": "UserList",
- "endpoints": ["/api/users", "/api/users/${user.id}"]
+ "endpoints": ["/api/users", "/api/users/:id"]
},
{
"component": "UserCard",
- "endpoints": ["/api/users/${user.id}"]
+ "endpoints": ["/api/users/:id"]
}
],
"queries": [
diff --git a/eval/history.jsonl b/eval/history.jsonl
index 6dc53e8..0840169 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -1 +1,2 @@
{"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1}
+{"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1}
diff --git a/eval/thresholds.json b/eval/thresholds.json
index cfebf43..01e8e4c 100644
--- a/eval/thresholds.json
+++ b/eval/thresholds.json
@@ -1,5 +1,7 @@
{
"maxFail": 0,
"maxUnexpectedPass": 0,
- "minMatchAccuracy": 1.0
+ "minMatchAccuracy": 1.0,
+ "minLineagePrecision": 0.9,
+ "minLineageRecall": 0.8
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index faeb3ce..af0ebff 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -95,6 +95,12 @@ export type DataSourceKind =
| "websocket"
| "unknown";
+/** How much of an endpoint was statically resolvable. */
+export type EndpointResolution =
+ | "full" // every segment is a known string
+ | "partial" // known shape with :param placeholders, e.g. "/api/users/:id"
+ | "none"; // nothing statically known ("")
+
/** An external data origin: an HTTP endpoint, GraphQL operation, or socket. */
export interface DataSourceNode extends BaseNode {
kind: "data-source";
@@ -102,10 +108,14 @@ export interface DataSourceNode extends BaseNode {
/** HTTP method when statically determinable. */
method: string | null;
/**
- * The endpoint as written in source — may contain template placeholders,
- * e.g. "/api/users/${id}".
+ * Canonical endpoint pattern: constants folded, template placeholders
+ * normalized to :param form — e.g. "/api/users/:id". This is the value
+ * attribution and cross-graph joins match on.
*/
endpoint: string;
+ /** The endpoint expression exactly as written in source. */
+ raw: string;
+ resolved: EndpointResolution;
}
export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown";
diff --git a/packages/parser-react/src/endpoint.test.ts b/packages/parser-react/src/endpoint.test.ts
new file mode 100644
index 0000000..ea68687
--- /dev/null
+++ b/packages/parser-react/src/endpoint.test.ts
@@ -0,0 +1,73 @@
+import { type Node, Project, SyntaxKind } from "ts-morph";
+import { describe, expect, it } from "vitest";
+
+import { resolveEndpoint } from "./endpoint.js";
+
+/** Parse a snippet and return the first argument of the fetch(...) call in it. */
+function fetchArg(code: string): Node | undefined {
+ const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } });
+ const sourceFile = project.createSourceFile("test.ts", code);
+ const call = sourceFile
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
+ .find((c) => c.getExpression().getText() === "fetch");
+ return call?.getArguments()[0];
+}
+
+describe("resolveEndpoint", () => {
+ it("resolves plain literals as full", () => {
+ expect(resolveEndpoint(fetchArg(`fetch("/api/a");`), [])).toEqual({
+ endpoint: "/api/a",
+ raw: `"/api/a"`,
+ resolved: "full",
+ });
+ });
+
+ it("folds a named constant", () => {
+ const arg = fetchArg(`const USERS = "/api/users"; fetch(USERS);`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/users", resolved: "full" });
+ });
+
+ it("folds an object member (ENDPOINTS.USERS)", () => {
+ const arg = fetchArg(`const E = { USERS: "/api/users" }; fetch(E.USERS);`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/users", resolved: "full" });
+ });
+
+ it("folds concatenation of constant + literal", () => {
+ const arg = fetchArg(`const P = "/api"; fetch(P + "/reports");`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/reports", resolved: "full" });
+ });
+
+ it("folds a fully-resolvable template", () => {
+ const arg = fetchArg(`const V = "v2"; fetch(\`/api/\${V}/users\`);`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/v2/users", resolved: "full" });
+ });
+
+ it("turns unknown template parts into :param placeholders", () => {
+ const arg = fetchArg(`declare const user: { id: string }; fetch(\`/api/users/\${user.id}\`);`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({
+ endpoint: "/api/users/:id",
+ resolved: "partial",
+ });
+ });
+
+ it("keeps the shape when the resource segment itself is dynamic", () => {
+ const arg = fetchArg(`declare const entity: string; fetch(\`/api/\${entity}/list\`);`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({
+ endpoint: "/api/:entity/list",
+ resolved: "partial",
+ });
+ });
+
+ it("reports none for fully-opaque expressions", () => {
+ const arg = fetchArg(`declare function buildUrl(): string; fetch(buildUrl());`);
+ expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "", resolved: "none" });
+ });
+
+ it("strips configured base URLs", () => {
+ const arg = fetchArg(`fetch("https://api.example.com/users");`);
+ expect(resolveEndpoint(arg, ["https://api.example.com"])).toMatchObject({
+ endpoint: "/users",
+ resolved: "full",
+ });
+ });
+});
diff --git a/packages/parser-react/src/endpoint.ts b/packages/parser-react/src/endpoint.ts
new file mode 100644
index 0000000..314cc0a
--- /dev/null
+++ b/packages/parser-react/src/endpoint.ts
@@ -0,0 +1,166 @@
+/**
+ * Static endpoint resolution (TRACKER step 1.1, failure modes C2/C3).
+ *
+ * Turns the expression passed to fetch/axios/useSWR into a canonical endpoint
+ * pattern: constants folded across files, template placeholders normalized to
+ * :param form, configured base-URL prefixes stripped.
+ *
+ * fetch(ENDPOINTS.USERS) → "/api/users" (full)
+ * fetch(`/api/orders/${orderId}`) → "/api/orders/:orderId" (partial)
+ * fetch(base + "/reports") → "/api/reports" (full, if base folds)
+ * fetch(buildUrl()) → "" (none)
+ */
+
+import type { EndpointResolution } from "@coderadar/core";
+import { Node, SyntaxKind } from "ts-morph";
+
+export interface ResolvedEndpoint {
+ endpoint: string;
+ raw: string;
+ resolved: EndpointResolution;
+}
+
+const MAX_FOLD_DEPTH = 6;
+
+export function resolveEndpoint(node: Node | undefined, baseUrls: string[]): ResolvedEndpoint {
+ if (node === undefined) {
+ return { endpoint: "", raw: "", resolved: "none" };
+ }
+ const raw = node.getText();
+
+ const full = resolveStringValue(node, 0);
+ if (full !== null) {
+ return { endpoint: stripBaseUrls(full, baseUrls), raw, resolved: "full" };
+ }
+
+ const partial = resolvePattern(node);
+ if (partial !== null) {
+ return { endpoint: stripBaseUrls(partial, baseUrls), raw, resolved: "partial" };
+ }
+
+ return { endpoint: "", raw, resolved: "none" };
+}
+
+/**
+ * Fold an expression to a known string: literals, cross-file constants
+ * (via go-to-definition), object members (ENDPOINTS.USERS), `+` concatenation,
+ * and fully-resolvable templates. Null when any part is unknown.
+ */
+export function resolveStringValue(node: Node | undefined, depth: number): string | null {
+ if (node === undefined || depth > MAX_FOLD_DEPTH) return null;
+
+ if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
+ return node.getLiteralValue();
+ }
+ if (
+ Node.isAsExpression(node) ||
+ Node.isSatisfiesExpression(node) ||
+ Node.isParenthesizedExpression(node)
+ ) {
+ return resolveStringValue(node.getExpression(), depth + 1);
+ }
+ if (Node.isIdentifier(node)) {
+ for (const definition of node.getDefinitionNodes()) {
+ if (Node.isVariableDeclaration(definition)) {
+ const value = resolveStringValue(definition.getInitializer(), depth + 1);
+ if (value !== null) return value;
+ }
+ }
+ return null;
+ }
+ if (Node.isPropertyAccessExpression(node)) {
+ const objectLiteral = resolveObjectLiteral(node.getExpression(), depth + 1);
+ const property = objectLiteral?.getProperty(node.getName());
+ if (property !== undefined && Node.isPropertyAssignment(property)) {
+ return resolveStringValue(property.getInitializer(), depth + 1);
+ }
+ return null;
+ }
+ if (
+ Node.isBinaryExpression(node) &&
+ node.getOperatorToken().getKind() === SyntaxKind.PlusToken
+ ) {
+ const left = resolveStringValue(node.getLeft(), depth + 1);
+ const right = resolveStringValue(node.getRight(), depth + 1);
+ return left !== null && right !== null ? left + right : null;
+ }
+ if (Node.isTemplateExpression(node)) {
+ let out = node.getHead().getLiteralText();
+ for (const span of node.getTemplateSpans()) {
+ const value = resolveStringValue(span.getExpression(), depth + 1);
+ if (value === null) return null;
+ out += value + span.getLiteral().getLiteralText();
+ }
+ return out;
+ }
+ return null;
+}
+
+/** Pattern form with :param placeholders for the unresolvable parts. */
+function resolvePattern(node: Node): string | null {
+ if (Node.isTemplateExpression(node)) {
+ let out = node.getHead().getLiteralText();
+ for (const span of node.getTemplateSpans()) {
+ const value = resolveStringValue(span.getExpression(), 0);
+ out += value ?? `:${placeholderName(span.getExpression())}`;
+ out += span.getLiteral().getLiteralText();
+ }
+ return out;
+ }
+ if (
+ Node.isBinaryExpression(node) &&
+ node.getOperatorToken().getKind() === SyntaxKind.PlusToken
+ ) {
+ const left = patternPart(node.getLeft());
+ const right = patternPart(node.getRight());
+ // A concatenation where *nothing* resolved carries no shape — report none.
+ if (left.startsWith(":") && right.startsWith(":")) return null;
+ return left + right;
+ }
+ return null;
+}
+
+function patternPart(node: Node): string {
+ const value = resolveStringValue(node, 0);
+ if (value !== null) return value;
+ const pattern = resolvePattern(node);
+ if (pattern !== null) return pattern;
+ return `:${placeholderName(node)}`;
+}
+
+/** ":id" for `user.id`, ":orderId" for `orderId`, ":param" for anything opaque. */
+function placeholderName(node: Node): string {
+ const match = /([A-Za-z_$][\w$]*)\s*$/.exec(node.getText());
+ return match?.[1] ?? "param";
+}
+
+function resolveObjectLiteral(node: Node | undefined, depth: number): import("ts-morph").ObjectLiteralExpression | null {
+ if (node === undefined || depth > MAX_FOLD_DEPTH) return null;
+ if (Node.isObjectLiteralExpression(node)) return node;
+ if (
+ Node.isAsExpression(node) ||
+ Node.isSatisfiesExpression(node) ||
+ Node.isParenthesizedExpression(node)
+ ) {
+ return resolveObjectLiteral(node.getExpression(), depth + 1);
+ }
+ if (Node.isIdentifier(node)) {
+ for (const definition of node.getDefinitionNodes()) {
+ if (Node.isVariableDeclaration(definition)) {
+ const literal = resolveObjectLiteral(definition.getInitializer(), depth + 1);
+ if (literal !== null) return literal;
+ }
+ }
+ }
+ return null;
+}
+
+function stripBaseUrls(endpoint: string, baseUrls: string[]): string {
+ for (const base of baseUrls) {
+ if (base.length > 0 && endpoint.startsWith(base)) {
+ const stripped = endpoint.slice(base.length);
+ return stripped.startsWith("/") ? stripped : `/${stripped}`;
+ }
+ }
+ return endpoint;
+}
diff --git a/packages/parser-react/src/scan.test.ts b/packages/parser-react/src/scan.test.ts
index 59faef6..9ae291f 100644
--- a/packages/parser-react/src/scan.test.ts
+++ b/packages/parser-react/src/scan.test.ts
@@ -39,10 +39,15 @@ describe("scanReact on the demo app", () => {
expect(instanceOf?.to).toBe(inst.definitionId);
});
- it("extracts endpoints with methods", () => {
- const sources = graph.nodes.filter((n) => n.kind === "data-source");
- const endpoints = sources.map((s) => (s.kind === "data-source" ? `${s.method} ${s.endpoint}` : ""));
- expect(endpoints.sort()).toEqual(["DELETE /api/users/${user.id}", "GET /api/users"]);
+ it("extracts endpoints with methods, resolution, and raw source", () => {
+ const sources = graph.nodes.flatMap((n) => (n.kind === "data-source" ? [n] : []));
+ const endpoints = sources.map((s) => `${s.method} ${s.endpoint} (${s.resolved})`);
+ expect(endpoints.sort()).toEqual([
+ "DELETE /api/users/:id (partial)",
+ "GET /api/users (full)",
+ ]);
+ const del = sources.find((s) => s.method === "DELETE");
+ expect(del?.raw).toBe("`/api/users/${user.id}`");
});
it("matches screenshot text to UserList via the envelope", () => {
@@ -57,7 +62,7 @@ describe("scanReact on the demo app", () => {
const lineage = result.candidates[0]?.value;
expect(lineage?.dataSources.map((d) => d.endpoint).sort()).toEqual([
"/api/users",
- "/api/users/${user.id}",
+ "/api/users/:id",
]);
expect(lineage?.state.map((s) => s.name).sort()).toEqual(["loading", "users"]);
expect(lineage?.events.map((e) => e.event)).toEqual(["onClick"]);
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index f72ae36..dd8bf2b 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -24,11 +24,19 @@ import {
type VariableDeclaration,
} from "ts-morph";
+import { resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js";
+
export interface ScanOptions {
/** Directory to scan. */
root: string;
/** Glob patterns relative to root. Default: all .tsx/.jsx plus hook-looking .ts files. */
include?: string[];
+ /**
+ * Base-URL prefixes stripped from resolved endpoints, e.g.
+ * ["https://api.example.com", "/v2"]. Keeps the graph's endpoint patterns
+ * environment-independent.
+ */
+ baseUrls?: string[];
}
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -68,6 +76,7 @@ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "
export function scanReact(options: ScanOptions): LineageGraph {
const root = path.resolve(options.root);
const include = options.include ?? ["**/*.tsx", "**/*.jsx", "**/*.ts"];
+ const baseUrls = options.baseUrls ?? [];
const project = new Project({
compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ },
@@ -116,7 +125,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName });
}
- extractBodyFacts(decl, id, file, nodes, addEdge);
+ extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls);
}
}
@@ -256,11 +265,12 @@ function extractBodyFacts(
file: string,
nodes: Map,
addEdge: (edge: LineageEdge) => void,
+ baseUrls: string[],
): void {
for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const callee = call.getExpression().getText();
- const dataSource = detectDataSource(call, callee);
+ const dataSource = detectDataSource(call, callee, baseUrls);
if (dataSource !== null) {
const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`);
if (!nodes.has(dsId)) {
@@ -272,6 +282,8 @@ function extractBodyFacts(
sourceKind: dataSource.sourceKind,
method: dataSource.method,
endpoint: dataSource.endpoint,
+ raw: dataSource.raw,
+ resolved: dataSource.resolved,
});
}
addEdge({ from: ownerId, to: dsId, kind: "fetches-from" });
@@ -328,14 +340,15 @@ function extractBodyFacts(
function detectDataSource(
call: CallExpression,
callee: string,
-): { sourceKind: DataSourceKind; method: string | null; endpoint: string } | null {
+ baseUrls: string[],
+): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null {
const firstArg = call.getArguments()[0];
if (callee === "fetch") {
return {
sourceKind: "fetch",
method: fetchMethod(call),
- endpoint: literalText(firstArg) ?? "",
+ ...resolveEndpoint(firstArg, baseUrls),
};
}
@@ -345,17 +358,27 @@ function detectDataSource(
return {
sourceKind: "axios",
method: method !== undefined && HTTP_METHODS.has(method) ? method.toUpperCase() : null,
- endpoint: literalText(firstArg) ?? "",
+ ...resolveEndpoint(firstArg, baseUrls),
};
}
if (callee === "useQuery" || callee === "useMutation" || callee === "useInfiniteQuery") {
- // Endpoint is inside the queryFn; surface the query key as the identity.
- return { sourceKind: "react-query", method: null, endpoint: literalText(firstArg) ?? call.getArguments()[0]?.getText().slice(0, 80) ?? "" };
+ // Endpoint lives inside the queryFn (followed in step 1.3); the query key
+ // is the identity meanwhile.
+ const resolved = resolveEndpoint(firstArg, baseUrls);
+ return {
+ sourceKind: "react-query",
+ method: null,
+ ...resolved,
+ endpoint:
+ resolved.resolved === "none"
+ ? (firstArg?.getText().slice(0, 80) ?? "")
+ : resolved.endpoint,
+ };
}
if (callee === "useSWR") {
- return { sourceKind: "swr", method: "GET", endpoint: literalText(firstArg) ?? "" };
+ return { sourceKind: "swr", method: "GET", ...resolveEndpoint(firstArg, baseUrls) };
}
return null;
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index c3ba0c5..8a8a5e1 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -312,7 +312,14 @@
},
"endpoint": {
"type": "string",
- "description": "The endpoint as written in source — may contain template placeholders, e.g. \"/api/users/${id}\"."
+ "description": "Canonical endpoint pattern: constants folded, template placeholders normalized to :param form — e.g. \"/api/users/:id\". This is the value attribution and cross-graph joins match on."
+ },
+ "raw": {
+ "type": "string",
+ "description": "The endpoint expression exactly as written in source."
+ },
+ "resolved": {
+ "$ref": "#/definitions/EndpointResolution"
}
},
"required": [
@@ -322,6 +329,8 @@
"loc",
"method",
"name",
+ "raw",
+ "resolved",
"sourceKind"
],
"additionalProperties": false,
@@ -339,6 +348,15 @@
"unknown"
]
},
+ "EndpointResolution": {
+ "type": "string",
+ "enum": [
+ "full",
+ "partial",
+ "none"
+ ],
+ "description": "How much of an endpoint was statically resolvable."
+ },
"StateNode": {
"type": "object",
"properties": {