From d17f89492ce8c1529e1a49131dd219d94f43e6c8 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:15:35 +0530 Subject: [PATCH] =?UTF-8?q?feat(parser-react):=20endpoint=20resolution=20?= =?UTF-8?q?=E2=80=94=20constant=20folding,=20route=20patterns,=20base-URL?= =?UTF-8?q?=20stripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.1 (TRACKER). Failure modes C2 (constants half), C3: - resolveEndpoint/resolveStringValue: cross-file constant folding via go-to-definition (named constants, ENDPOINTS.X object members, + concat, as/satisfies unwrap, depth-capped), template literals with unknown parts normalized to :param placeholders (/api/users/${user.id} → /api/users/:id), configured baseUrls stripped from resolved endpoints. - DataSourceNode gains raw (source text) + resolved (full|partial|none); endpoint is now the canonical pattern — the cross-graph join key. - Fixtures c2-endpoint-constants (object member / named const / concat) and c3-dynamic-endpoints (dynamic id + dynamic resource segment), both with -collapse forbidden assertions. - Thresholds ratcheted: minLineagePrecision 0.9, minLineageRecall 0.8. Scorecard: 38 pass / 0 fail / 2 xfail; precision 1.000, recall 0.833 (was 0.714), match accuracy 1.000. 33 unit tests. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 10 +- .../c2-endpoint-constants/app/HealthBadge.tsx | 15 ++ .../app/ReportsPanel.tsx | 24 +++ .../c2-endpoint-constants/app/UsersPanel.tsx | 24 +++ .../app/api/endpoints.ts | 8 + .../c2-endpoint-constants/golden.json | 27 +++ .../c3-dynamic-endpoints/app/OrderDetail.tsx | 27 +++ .../fixtures/c3-dynamic-endpoints/golden.json | 21 +++ eval/fixtures/demo-app/golden.json | 4 +- eval/history.jsonl | 1 + eval/thresholds.json | 4 +- packages/core/src/types.ts | 14 +- packages/parser-react/src/endpoint.test.ts | 73 ++++++++ packages/parser-react/src/endpoint.ts | 166 ++++++++++++++++++ packages/parser-react/src/scan.test.ts | 15 +- packages/parser-react/src/scan.ts | 39 +++- schemas/lineage-graph.schema.json | 20 ++- 17 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx create mode 100644 eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx create mode 100644 eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx create mode 100644 eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts create mode 100644 eval/fixtures/c2-endpoint-constants/golden.json create mode 100644 eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx create mode 100644 eval/fixtures/c3-dynamic-endpoints/golden.json create mode 100644 packages/parser-react/src/endpoint.test.ts create mode 100644 packages/parser-react/src/endpoint.ts 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": {