From b4825f0ebd98d672b7d8e843efd3ce07fe17fc02 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 12:21:04 +0530 Subject: [PATCH] =?UTF-8?q?feat(response):=20response-schema=20linking=20?= =?UTF-8?q?=E2=80=94=20generic/annotation/OpenAPI=20(5.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataSourceNode gains an optional ResponseType { name, fields, source } (one level of fields); lineage-graph and context-bundle schemas regenerated, drift gates green. New response.ts parser module: - responseFromCall: recovers the type from a call's generic argument (axios.get, useQuery) or, failing that, the annotation on the nearest enclosing typed variable whose initializer holds the call (const data: Invoice[] = await fetch(...).then(r => r.json())). Stops at function boundaries; reads property signatures only (methods skipped). - loadOpenApi / linkOpenApiResponses: post-pass filling untyped sources from an OpenAPI 3 JSON spec, matching `${METHOD} ${endpoint}` with {id}->:id normalization and $ref resolution. Exposed via the `openapi` scan option and CLI `scan --openapi`. Bundle lineage dataSources carry responseType; `trace` prints it. New f4-typed-responses fixture + GoldenResponse / `responses` check kind covering all three sources. 5 parser unit tests incl. bundle-level. eval 265/0/0, gate OK. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 7 +- .../f4-typed-responses/app/InvoicesPage.tsx | 24 +++ .../f4-typed-responses/app/OrdersPage.tsx | 20 ++ .../f4-typed-responses/app/UsersPage.tsx | 20 ++ .../f4-typed-responses/app/openapi.json | 35 +++ eval/fixtures/f4-typed-responses/app/types.ts | 11 + eval/fixtures/f4-typed-responses/golden.json | 27 +++ eval/src/checks.ts | 29 +++ eval/src/golden.ts | 22 +- packages/agent-sdk/src/bundle.ts | 26 ++- packages/cli/README.md | 1 + packages/cli/src/index.ts | 12 +- packages/core/src/types.ts | 23 ++ packages/parser-react/src/response.test.ts | 58 +++++ packages/parser-react/src/response.ts | 200 ++++++++++++++++++ packages/parser-react/src/scan.ts | 22 ++ schemas/context-bundle.schema.json | 74 +++++-- schemas/lineage-graph.schema.json | 53 +++++ 18 files changed, 635 insertions(+), 29 deletions(-) create mode 100644 eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx create mode 100644 eval/fixtures/f4-typed-responses/app/OrdersPage.tsx create mode 100644 eval/fixtures/f4-typed-responses/app/UsersPage.tsx create mode 100644 eval/fixtures/f4-typed-responses/app/openapi.json create mode 100644 eval/fixtures/f4-typed-responses/app/types.ts create mode 100644 eval/fixtures/f4-typed-responses/golden.json create mode 100644 packages/parser-react/src/response.test.ts create mode 100644 packages/parser-react/src/response.ts diff --git a/TRACKER.md b/TRACKER.md index c8ad723..39d7c62 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 5 — Context bundle & agent interface -- **Next step:** 5.5 — Response-schema linking -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.4 +- **Next step:** 5.6 — Git history +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.5 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) ## What CodeRadar is @@ -291,10 +291,11 @@ The heart of the project. C1 and B1 live here. **Accept:** fixture with co-located tests: bundle names the right test files; components without tests get `warnings: ["untested"]`. **Done:** `TestNode` (kind `test`, framework vitest/jest/unknown) + `covered-by` edge in core (schema regenerated, drift gate green). New `detectTests` parser pass: test files are excluded from the component/instance scan (`isTestFile`) so they never emit spurious nodes, then swept — every component a test renders (JSX tag) or imports resolves to a `covered-by` edge (imports resolved to their source file for precise attribution, name fallback otherwise). Bundle populates `tests` from the matched component's render subtree (`componentSubtree`) and pushes an `untested` warning when the matched component has no coverage; `blastRadius` counts a test as a dependent of the component it covers. New `f3-test-coverage` fixture + `GoldenCoverage`/`coverage` check kind (covers UserList, Sidebar untested). 6 parser unit tests (incl. two bundle-level); eval 262/0/0, gate OK. -### [ ] 5.5 Response-schema linking +### [x] 5.5 Response-schema linking **Failure modes:** F4 **Build:** data sources link to response types: generic argument (`useQuery`), annotated variable types, or an OpenAPI spec (scan option `openapi: path`) matched by endpoint pattern. Bundle lineage entries carry `responseType: { name, fields }` (one level of fields, not deep). **Accept:** fixture `f4-typed-responses` green for all three sources (generic, annotation, OpenAPI). +**Done:** `ResponseType { name, fields: {name,type}[], source }` on `DataSourceNode` in core (schema regenerated, drift gate green). New `response.ts` parser module: `responseFromCall` recovers the type from a call's generic argument (`axios.get`, `useQuery`) or, failing that, the annotation on the nearest enclosing typed variable whose initializer holds the call (`const data: Invoice[] = await fetch(…).then(r => r.json())`), stopping at function boundaries; only property signatures are read (one level, methods skipped). `loadOpenApi`/`linkOpenApiResponses` is a post-pass that fills untyped sources from an OpenAPI 3 JSON spec (`openapi` scan option / CLI `--openapi`), matching `${METHOD} ${endpoint}` with `{id}`→`:id` normalization and `$ref` resolution. Bundle lineage `dataSources` carry `responseType`; `trace` prints it. New `f4-typed-responses` fixture + `GoldenResponse`/`responses` check kind (all three sources). 5 parser unit tests incl. bundle-level; eval 265/0/0, gate OK. ### [ ] 5.6 Git history context **Failure modes:** F5 diff --git a/eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx b/eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx new file mode 100644 index 0000000..fffa28d --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +import type { Invoice } from "./types"; + +// Source 2 (annotation): the response type is the annotation on the variable +// the fetch result lands in. +export function InvoicesPage() { + const [invoices, setInvoices] = useState([]); + useEffect(() => { + async function load() { + const data: Invoice[] = await fetch("/api/invoices").then((r) => r.json()); + setInvoices(data); + } + void load(); + }, []); + return ( +
+

Invoices

+ {invoices.map((i) => ( +

{i.number}

+ ))} +
+ ); +} diff --git a/eval/fixtures/f4-typed-responses/app/OrdersPage.tsx b/eval/fixtures/f4-typed-responses/app/OrdersPage.tsx new file mode 100644 index 0000000..4a68323 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/OrdersPage.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +// Source 3 (OpenAPI): the code says nothing about the shape — the response type +// is recovered from the spec by matching GET /api/orders. +export function OrdersPage() { + const [orders, setOrders] = useState[]>([]); + useEffect(() => { + fetch("/api/orders") + .then((r) => r.json()) + .then(setOrders); + }, []); + return ( +
+

Orders

+ {orders.map((o, i) => ( +

{String(o.id)}

+ ))} +
+ ); +} diff --git a/eval/fixtures/f4-typed-responses/app/UsersPage.tsx b/eval/fixtures/f4-typed-responses/app/UsersPage.tsx new file mode 100644 index 0000000..7efaa9d --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/UsersPage.tsx @@ -0,0 +1,20 @@ +import axios from "axios"; +import { useEffect, useState } from "react"; + +import type { User } from "./types"; + +// Source 1 (generic): the response type is the call's type argument. +export function UsersPage() { + const [users, setUsers] = useState([]); + useEffect(() => { + axios.get("/api/users").then((res) => setUsers(res.data)); + }, []); + return ( +
+

Users

+ {users.map((u) => ( +

{u.name}

+ ))} +
+ ); +} diff --git a/eval/fixtures/f4-typed-responses/app/openapi.json b/eval/fixtures/f4-typed-responses/app/openapi.json new file mode 100644 index 0000000..0e686b9 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/openapi.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Fixture API", "version": "1.0.0" }, + "paths": { + "/api/orders": { + "get": { + "responses": { + "200": { + "description": "orders", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Order" } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "status": { "type": "string" }, + "total": { "type": "number" } + } + } + } + } +} diff --git a/eval/fixtures/f4-typed-responses/app/types.ts b/eval/fixtures/f4-typed-responses/app/types.ts new file mode 100644 index 0000000..4705040 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/types.ts @@ -0,0 +1,11 @@ +export interface User { + id: number; + name: string; + email: string; +} + +export interface Invoice { + id: number; + number: string; + total: number; +} diff --git a/eval/fixtures/f4-typed-responses/golden.json b/eval/fixtures/f4-typed-responses/golden.json new file mode 100644 index 0000000..95aaf60 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/golden.json @@ -0,0 +1,27 @@ +{ + "failureMode": "F4", + "note": "Response-schema linking from all three sources: a generic type argument (axios.get), a variable-type annotation (const data: Invoice[] = …fetch…), and an OpenAPI spec matched by endpoint (GET /api/orders → Order[]).", + "scan": { "openapi": "openapi.json" }, + "expect": { + "responses": [ + { + "endpoint": "/api/users", + "name": "User[]", + "from": "generic", + "fields": ["id", "name", "email"] + }, + { + "endpoint": "/api/invoices", + "name": "Invoice[]", + "from": "annotation", + "fields": ["id", "number", "total"] + }, + { + "endpoint": "/api/orders", + "name": "Order[]", + "from": "openapi", + "fields": ["id", "status", "total"] + } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index b57e92c..f5d44a1 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -351,6 +351,35 @@ export function runChecks( } } + for (const spec of golden.expect.responses ?? []) { + const id = `response:${spec.endpoint}=>${spec.name}`; + const source = graph.nodes.find( + (n) => + n.kind === "data-source" && + n.endpoint === spec.endpoint && + (spec.method === undefined || n.method === spec.method), + ); + const rt = source?.kind === "data-source" ? source.responseType : undefined; + let passed = rt !== undefined && rt.name === spec.name; + let detail: string | undefined; + if (source === undefined) detail = `no data source for ${spec.endpoint}`; + else if (rt === undefined) detail = `no response type on ${spec.endpoint}`; + else if (rt.name !== spec.name) detail = `expected response ${spec.name}, got ${rt.name}`; + if (passed && rt !== undefined && spec.from !== undefined && rt.source !== spec.from) { + passed = false; + detail = `expected source ${spec.from}, got ${rt.source}`; + } + if (passed && rt !== undefined && spec.fields !== undefined) { + const have = new Set(rt.fields.map((f) => f.name)); + const missing = spec.fields.filter((f) => !have.has(f)); + if (missing.length > 0) { + passed = false; + detail = `missing fields [${missing.join(", ")}] (have [${[...have].join(", ")}])`; + } + } + finalize("responses", id, passed, spec.expectedFail, detail); + } + 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 1f633a1..80b14f1 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -104,6 +104,21 @@ export interface GoldenCondition { expectedFail?: string; } +/** A response-schema assertion (step 5.5, failure mode F4): a data source's response type. */ +export interface GoldenResponse { + /** Endpoint the data source resolves to. */ + endpoint: string; + /** HTTP method, to disambiguate same-endpoint sources. */ + method?: string; + /** Expected response-type name (e.g. "User[]"). */ + name: string; + /** Where the type must have come from. */ + from?: "generic" | "annotation" | "openapi"; + /** Field names that must appear in the response type. */ + fields?: string[]; + expectedFail?: string; +} + /** A test-coverage assertion (step 5.4, failure mode F3): covered-by edges. */ export interface GoldenCoverage { component: string; @@ -155,6 +170,8 @@ export interface Golden { baseUrls?: string[]; apiWrappers?: string[]; i18n?: { localeGlobs: string[]; defaultLocale: string }; + /** OpenAPI spec path (relative to the app dir) for response-schema linking (5.5). */ + openapi?: string; }; expect: { components?: GoldenComponent[]; @@ -176,6 +193,8 @@ export interface Golden { blast?: GoldenBlast[]; /** Test coverage via covered-by edges (step 5.4, F3). */ coverage?: GoldenCoverage[]; + /** Response types on data sources (step 5.5, F4). */ + responses?: GoldenResponse[]; }; } @@ -195,7 +214,8 @@ export interface CheckResult { | "conditions" | "externals" | "blast" - | "coverage"; + | "coverage" + | "responses"; status: CheckStatus; detail?: string; } diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts index 46b16b7..55056b6 100644 --- a/packages/agent-sdk/src/bundle.ts +++ b/packages/agent-sdk/src/bundle.ts @@ -11,6 +11,7 @@ */ import { blastRadius, + type DataSourceNode, type ImpactNode, type JourneyPath, journeys, @@ -29,10 +30,17 @@ export interface BundleMatch { evidence: string[]; } +export interface BundleDataSource { + method: string | null; + endpoint: string; + /** Response shape (5.5, F4), when recoverable — name + one level of fields. */ + responseType?: { name: string; fields: { name: string; type: string }[]; source: string }; +} + export interface BundleLineageEntry { /** Component name, or `Name@file:line` for a specific instance. */ target: string; - dataSources: { method: string | null; endpoint: string }[]; + dataSources: BundleDataSource[]; state: string[]; events: string[]; } @@ -136,10 +144,7 @@ export function buildBundle( if (definitionLineage !== undefined) { bundle.lineage.push({ target: top.component.name, - dataSources: definitionLineage.dataSources.map((d) => ({ - method: d.method, - endpoint: d.endpoint, - })), + dataSources: definitionLineage.dataSources.map(bundleDataSource), state: definitionLineage.state.map((s) => s.name), events: definitionLineage.events.map((e) => e.event), }); @@ -149,7 +154,7 @@ export function buildBundle( if (instLineage === undefined || instLineage.dataSources.length === 0) continue; bundle.lineage.push({ target: `${top.component.name}@${instance.loc.file}:${instance.loc.line}`, - dataSources: instLineage.dataSources.map((d) => ({ method: d.method, endpoint: d.endpoint })), + dataSources: instLineage.dataSources.map(bundleDataSource), state: [], events: [], }); @@ -187,6 +192,15 @@ export function buildBundle( return trimToBudget(bundle, budgetTokens); } +/** Project a data-source node into the bundle shape, carrying its response type when present. */ +function bundleDataSource(d: DataSourceNode): BundleDataSource { + return { + method: d.method, + endpoint: d.endpoint, + ...(d.responseType !== undefined ? { responseType: d.responseType } : {}), + }; +} + /** Component ids in the render subtree of `rootId` (itself plus renders → instance-of descendants). */ function componentSubtree(graph: LineageGraph, rootId: string): Set { const rendersFrom = new Map(); diff --git a/packages/cli/README.md b/packages/cli/README.md index 073dbae..fbd985d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -21,6 +21,7 @@ Requires Node ≥ 20. ```bash ui-lineage scan ./src -o app.graph.json # scan a React app into a graph +ui-lineage scan ./src --openapi openapi.json # …and link data sources to response types ui-lineage find "All invoices" -g app.graph.json # text → component ui-lineage find "invoice widget" -a aliases.yaml # resolve business vocabulary ui-lineage trace InvoicesPage -g app.graph.json # component → data/state/events diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index aa2c97c..5d449af 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -37,9 +37,13 @@ program .description("Scan a React codebase and emit a lineage graph JSON") .argument("", "directory to scan") .option("-o, --out ", "output file", "ui-lineage.graph.json") - .action((dir: string, opts: { out: string }) => { + .option("--openapi ", "OpenAPI 3 JSON spec (relative to ) for response-schema linking") + .action((dir: string, opts: { out: string; openapi?: string }) => { const meta = collectGraphMeta(path.resolve(dir)); - const graph = { ...resolveHookEdges(scanReact({ root: dir })), meta }; + const graph = { + ...resolveHookEdges(scanReact({ root: dir, ...(opts.openapi ? { openapi: opts.openapi } : {}) })), + meta, + }; saveGraph(graph, opts.out); const counts = new Map(); for (const node of graph.nodes) { @@ -119,6 +123,10 @@ program console.log(" data sources:"); for (const ds of lineage.dataSources) { console.log(` [${ds.sourceKind}] ${ds.method ?? "?"} ${ds.endpoint} (${ds.loc.file}:${ds.loc.line})`); + if (ds.responseType !== undefined) { + const fields = ds.responseType.fields.map((f) => `${f.name}: ${f.type}`).join(", "); + console.log(` → ${ds.responseType.name} { ${fields} } (${ds.responseType.source})`); + } } } if (lineage.state.length > 0) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 450ad59..f04a3e8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -169,6 +169,27 @@ export type EndpointResolution = | "partial" // known shape with :param placeholders, e.g. "/api/users/:id" | "none"; // nothing statically known ("") +/** One field of a response type — a name and its type as written. */ +export interface ResponseField { + name: string; + /** Field type text, e.g. "string", "number | null", "Address". */ + type: string; +} + +/** + * The shape a data source returns (TRACKER step 5.4→5.5, failure mode F4). + * Resolved from a call's generic argument (`useQuery`), a type + * annotation on the variable the result lands in, or an OpenAPI spec matched by + * endpoint. One level deep only — fields, not their nested shapes. + */ +export interface ResponseType { + /** Named type when resolvable ("User", "User[]"); otherwise the type text. */ + name: string; + fields: ResponseField[]; + /** Where the type was recovered from. */ + source: "generic" | "annotation" | "openapi"; +} + /** An external data origin: an HTTP endpoint, GraphQL operation, or socket. */ export interface DataSourceNode extends BaseNode { kind: "data-source"; @@ -189,6 +210,8 @@ export interface DataSourceNode extends BaseNode { * the identity used for cache-sharing analysis (C7) alongside the endpoint. */ queryKey?: string; + /** The response shape this source returns, when recoverable (5.5, F4). */ + responseType?: ResponseType; } export type StateKind = diff --git a/packages/parser-react/src/response.test.ts b/packages/parser-react/src/response.test.ts new file mode 100644 index 0000000..b7d3530 --- /dev/null +++ b/packages/parser-react/src/response.test.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildBundle } from "@coderadar/agent-sdk"; +import type { DataSourceNode, LineageGraph } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { scanReact } from "./scan.js"; + +const appDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/f4-typed-responses/app", +); + +const withOpenApi: LineageGraph = scanReact({ root: appDir, openapi: "openapi.json" }); +const withoutOpenApi: LineageGraph = scanReact({ root: appDir }); + +const source = (graph: LineageGraph, endpoint: string): DataSourceNode | undefined => + graph.nodes.find( + (n): n is DataSourceNode => n.kind === "data-source" && n.endpoint === endpoint, + ); + +describe("response-schema linking (TRACKER 5.5)", () => { + it("recovers a response type from a call's generic argument", () => { + const rt = source(withOpenApi, "/api/users")?.responseType; + expect(rt?.name).toBe("User[]"); + expect(rt?.source).toBe("generic"); + expect(rt?.fields.map((f) => f.name)).toEqual(["id", "name", "email"]); + }); + + it("recovers a response type from a variable annotation", () => { + const rt = source(withOpenApi, "/api/invoices")?.responseType; + expect(rt?.name).toBe("Invoice[]"); + expect(rt?.source).toBe("annotation"); + expect(rt?.fields.map((f) => f.name)).toEqual(["id", "number", "total"]); + }); + + it("recovers a response type from an OpenAPI spec by endpoint", () => { + const rt = source(withOpenApi, "/api/orders")?.responseType; + expect(rt?.name).toBe("Order[]"); + expect(rt?.source).toBe("openapi"); + expect(rt?.fields.map((f) => f.name)).toEqual(["id", "status", "total"]); + }); + + it("leaves the OpenAPI-only source untyped when no spec is supplied", () => { + expect(source(withoutOpenApi, "/api/orders")?.responseType).toBeUndefined(); + // The code-level sources still resolve without a spec. + expect(source(withoutOpenApi, "/api/users")?.responseType?.name).toBe("User[]"); + }); + + it("carries the response type through into the context bundle lineage", () => { + const bundle = buildBundle(withOpenApi, { text: "Users" }, { budgetTokens: 8000 }); + const users = bundle.lineage + .flatMap((entry) => entry.dataSources) + .find((d) => d.endpoint === "/api/users"); + expect(users?.responseType?.name).toBe("User[]"); + }); +}); diff --git a/packages/parser-react/src/response.ts b/packages/parser-react/src/response.ts new file mode 100644 index 0000000..c39f2fc --- /dev/null +++ b/packages/parser-react/src/response.ts @@ -0,0 +1,200 @@ +/** + * Response-schema linking (TRACKER step 5.5, failure mode F4). + * + * Recovers the shape a data source returns from three sources, in order: + * 1. a generic type argument on the fetching call — `useQuery(…)`, + * `axios.get(url)`, `useSWR(…)`; + * 2. a type annotation on the variable the result lands in — + * `const users: User[] = await fetch(…).then((r) => r.json())`; + * 3. an OpenAPI 3 spec, matched by endpoint + method (a post-pass, since it + * needs only the resolved endpoint, not the call). + * + * Only one level of fields is recorded — the fields of the response, not their + * nested shapes. + */ + +import fs from "node:fs"; +import path from "node:path"; + +import type { LineageNode, ResponseField, ResponseType } from "@coderadar/core"; +import { type CallExpression, Node, type Type, type TypeNode } from "ts-morph"; + +const MAX_FIELDS = 40; +const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); + +/** Recover a response type from a fetching call's generics or a nearby annotation. */ +export function responseFromCall(call: CallExpression): ResponseType | null { + const typeArg = call.getTypeArguments()[0]; + if (typeArg !== undefined) { + const fromGeneric = fromTypeNode(typeArg, "generic"); + if (fromGeneric !== null) return fromGeneric; + } + // Nearest enclosing typed variable whose initializer holds this call — + // stop at a function boundary so we never grab an unrelated outer binding. + let node: Node | undefined = call.getParent(); + while (node !== undefined) { + if ( + Node.isFunctionDeclaration(node) || + Node.isArrowFunction(node) || + Node.isFunctionExpression(node) || + Node.isMethodDeclaration(node) + ) { + break; + } + if (Node.isVariableDeclaration(node)) { + const typeNode = node.getTypeNode(); + return typeNode !== undefined ? fromTypeNode(typeNode, "annotation") : null; + } + node = node.getParent(); + } + return null; +} + +function fromTypeNode(typeNode: TypeNode, source: ResponseType["source"]): ResponseType | null { + const type = typeNode.getType(); + const isArray = type.isArray(); + const element = isArray ? (type.getArrayElementType() ?? type) : type; + const symbolName = element.getSymbol()?.getName(); + const named = symbolName !== undefined && symbolName !== "__type" && symbolName !== "__object"; + const name = named ? (isArray ? `${symbolName}[]` : symbolName) : typeNode.getText(); + return { name, fields: fieldsOf(element, typeNode), source }; +} + +/** One level of data fields — property signatures/declarations only (skips methods). */ +function fieldsOf(type: Type, at: Node): ResponseField[] { + const fields: ResponseField[] = []; + for (const property of type.getProperties()) { + const decl = property.getDeclarations()[0]; + if (decl === undefined) continue; + if (!Node.isPropertySignature(decl) && !Node.isPropertyDeclaration(decl)) continue; + const fieldType = simplifyType(property.getTypeAtLocation(at).getText(at)); + fields.push({ name: property.getName(), type: fieldType }); + if (fields.length >= MAX_FIELDS) break; + } + return fields; +} + +function simplifyType(text: string): string { + // Drop `import("/abs/path").` prefixes the checker emits for cross-file types. + const cleaned = text.replace(/import\([^)]*\)\./g, ""); + return cleaned.length > 80 ? `${cleaned.slice(0, 77)}…` : cleaned; +} + +// --- OpenAPI ------------------------------------------------------------------ + +type OpenApiIndex = Map; + +interface OpenApiSchema { + $ref?: string; + type?: string; + title?: string; + items?: OpenApiSchema; + properties?: Record; +} + +/** Load an OpenAPI 3 JSON spec into a `${METHOD} ${endpoint}` → ResponseType index. */ +export function loadOpenApi(root: string, relPath: string): OpenApiIndex | null { + const file = path.resolve(root, relPath); + if (!fs.existsSync(file)) return null; + let spec: { + paths?: Record }>>; + components?: { schemas?: Record }; + }; + try { + spec = JSON.parse(fs.readFileSync(file, "utf-8")); + } catch { + return null; + } + const schemas = spec.components?.schemas ?? {}; + const index: OpenApiIndex = new Map(); + for (const [rawPath, operations] of Object.entries(spec.paths ?? {})) { + const endpoint = normalizeOpenApiPath(rawPath); + for (const [method, operation] of Object.entries(operations)) { + const httpMethod = method.toUpperCase(); + if (!HTTP_METHODS.has(httpMethod)) continue; + const schema = successSchema(operation.responses); + if (schema === undefined) continue; + const responseType = responseTypeFromSchema(schema, schemas); + if (responseType !== null) index.set(`${httpMethod} ${endpoint}`, responseType); + } + } + return index; +} + +interface ResponseObject { + content?: Record; +} + +function successSchema( + responses: Record | undefined, +): OpenApiSchema | undefined { + for (const code of ["200", "201"]) { + const schema = responses?.[code]?.content?.["application/json"]?.schema; + if (schema !== undefined) return schema; + } + return undefined; +} + +/** Convert an OpenAPI path template ("/users/{id}") to our endpoint form ("/users/:id"). */ +function normalizeOpenApiPath(p: string): string { + return p.replace(/\{([^}]+)\}/g, ":$1"); +} + +function refName(ref: string): string { + return ref.split("/").pop() ?? ref; +} + +function responseTypeFromSchema( + schema: OpenApiSchema, + schemas: Record, +): ResponseType | null { + if (schema.$ref !== undefined) { + const name = refName(schema.$ref); + return { name, fields: schemaFields(schemas[name], schemas), source: "openapi" }; + } + if (schema.type === "array" && schema.items !== undefined) { + const item = schema.items; + if (item.$ref !== undefined) { + const name = refName(item.$ref); + return { name: `${name}[]`, fields: schemaFields(schemas[name], schemas), source: "openapi" }; + } + return { name: `${item.type ?? "object"}[]`, fields: schemaFields(item, schemas), source: "openapi" }; + } + if (schema.type === "object" || schema.properties !== undefined) { + return { name: schema.title ?? "object", fields: schemaFields(schema, schemas), source: "openapi" }; + } + return null; +} + +function schemaFields( + schema: OpenApiSchema | undefined, + schemas: Record, +): ResponseField[] { + if (schema === undefined) return []; + const resolved = schema.$ref !== undefined ? schemas[refName(schema.$ref)] : schema; + const properties = resolved?.properties ?? {}; + const fields: ResponseField[] = []; + for (const [name, prop] of Object.entries(properties)) { + fields.push({ name, type: schemaFieldType(prop) }); + if (fields.length >= MAX_FIELDS) break; + } + return fields; +} + +function schemaFieldType(schema: OpenApiSchema): string { + if (schema.$ref !== undefined) return refName(schema.$ref); + if (schema.type === "array" && schema.items !== undefined) { + return `${schema.items.$ref !== undefined ? refName(schema.items.$ref) : (schema.items.type ?? "object")}[]`; + } + return schema.type ?? "unknown"; +} + +/** Post-pass: attach OpenAPI response types to data sources that lack one. */ +export function linkOpenApiResponses(nodes: Map, index: OpenApiIndex): void { + for (const node of nodes.values()) { + if (node.kind !== "data-source" || node.responseType !== undefined) continue; + const method = (node.method ?? "GET").toUpperCase(); + const match = index.get(`${method} ${node.endpoint}`) ?? index.get(`GET ${node.endpoint}`); + if (match !== undefined) node.responseType = match; + } +} diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 4fa02bf..52e8473 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -10,6 +10,7 @@ import { type LineageNode, nodeId, type RenderedText, + type ResponseType, type SourceLocation, type StructuralSignature, } from "@coderadar/core"; @@ -29,6 +30,7 @@ import { import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js"; import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; +import { linkOpenApiResponses, loadOpenApi, responseFromCall } from "./response.js"; import { detectRoutes } from "./routes.js"; import { detectTests, isTestFile } from "./tests.js"; import { detectWrappers, type WrapperRegistry } from "./wrappers.js"; @@ -69,6 +71,13 @@ export interface ScanOptions { * (TRACKER step 3.5, failure modes G5/B5). */ featureFlags?: string[]; + /** + * Path (relative to `root`) to an OpenAPI 3 JSON spec. When set, data sources + * whose response type can't be recovered from the code are matched to the + * spec by endpoint + method, so lineage entries still carry a response shape + * (TRACKER step 5.5, failure mode F4). + */ + openapi?: string; } type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression; @@ -244,6 +253,11 @@ export function scanReact(options: ScanOptions): LineageGraph { ); // Tests last: components must exist before we can attach coverage to them. detectTests(project, root, nodes, addEdge); + // OpenAPI fills response types the code didn't spell out (5.5, F4). + if (options.openapi !== undefined) { + const openApi = loadOpenApi(root, options.openapi); + if (openApi !== null) linkOpenApiResponses(nodes, openApi); + } return { version: 2, @@ -259,6 +273,11 @@ function toPosix(p: string): string { return p.split(path.sep).join("/"); } +/** Spread helper: emit a `responseType` field only when one was recovered (5.5). */ +function responseTypeProp(rt: ResponseType | null): { responseType?: ResponseType } { + return rt !== null ? { responseType: rt } : {}; +} + function locOf(node: Node, file: string): SourceLocation { return { file, line: node.getStartLineNumber(), endLine: node.getEndLineNumber() }; } @@ -698,6 +717,7 @@ function extractBodyFacts( raw: dataSource.raw, resolved: dataSource.resolved, ...(dataSource.queryKey !== undefined ? { queryKey: dataSource.queryKey } : {}), + ...responseTypeProp(responseFromCall(call)), }); } addEdge({ from: ownerId, to: dsId, kind: "fetches-from" }); @@ -1042,6 +1062,7 @@ function detectStores( endpoint: detected.endpoint, raw: detected.raw, resolved: detected.resolved, + ...responseTypeProp(responseFromCall(call)), }); } return dsId; @@ -1870,6 +1891,7 @@ function resolveHandlerChains( endpoint: detected.endpoint, raw: detected.raw, resolved: detected.resolved, + ...responseTypeProp(responseFromCall(call)), }); } pushEffect(effects, { kind: "triggers", to: dsId }); diff --git a/schemas/context-bundle.schema.json b/schemas/context-bundle.schema.json index 7baeec8..31683fe 100644 --- a/schemas/context-bundle.schema.json +++ b/schemas/context-bundle.schema.json @@ -158,23 +158,7 @@ "dataSources": { "type": "array", "items": { - "type": "object", - "properties": { - "method": { - "type": [ - "string", - "null" - ] - }, - "endpoint": { - "type": "string" - } - }, - "required": [ - "method", - "endpoint" - ], - "additionalProperties": false + "$ref": "#/definitions/BundleDataSource" } }, "state": { @@ -198,6 +182,62 @@ ], "additionalProperties": false }, + "BundleDataSource": { + "type": "object", + "properties": { + "method": { + "type": [ + "string", + "null" + ] + }, + "endpoint": { + "type": "string" + }, + "responseType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "additionalProperties": false + } + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "fields", + "source" + ], + "additionalProperties": false, + "description": "Response shape (5.5, F4), when recoverable — name + one level of fields." + } + }, + "required": [ + "method", + "endpoint" + ], + "additionalProperties": false + }, "JourneyPath": { "type": "object", "properties": { diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 8d7523f..28b8f9c 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -430,6 +430,10 @@ "queryKey": { "type": "string", "description": "react-query/SWR cache key as written in source (e.g. `[\"users\"]`) — the identity used for cache-sharing analysis (C7) alongside the endpoint." + }, + "responseType": { + "$ref": "#/definitions/ResponseType", + "description": "The response shape this source returns, when recoverable (5.5, F4)." } }, "required": [ @@ -467,6 +471,55 @@ ], "description": "How much of an endpoint was statically resolvable." }, + "ResponseType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Named type when resolvable (\"User\", \"User[]\"); otherwise the type text." + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/ResponseField" + } + }, + "source": { + "type": "string", + "enum": [ + "generic", + "annotation", + "openapi" + ], + "description": "Where the type was recovered from." + } + }, + "required": [ + "name", + "fields", + "source" + ], + "additionalProperties": false, + "description": "The shape a data source returns (TRACKER step 5.4→5.5, failure mode F4). Resolved from a call's generic argument (`useQuery`), a type annotation on the variable the result lands in, or an OpenAPI spec matched by endpoint. One level deep only — fields, not their nested shapes." + }, + "ResponseField": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Field type text, e.g. \"string\", \"number | null\", \"Address\"." + } + }, + "required": [ + "name", + "type" + ], + "additionalProperties": false, + "description": "One field of a response type — a name and its type as written." + }, "StateNode": { "type": "object", "properties": {