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
7 changes: 4 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 5 — Context bundle & agent interface
- **Next step:** 5.5Response-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.6Git 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
Expand Down Expand Up @@ -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<User[]>`), 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<User[]>`, `useQuery<T>`) 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
Expand Down
24 changes: 24 additions & 0 deletions eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx
Original file line number Diff line number Diff line change
@@ -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<Invoice[]>([]);
useEffect(() => {
async function load() {
const data: Invoice[] = await fetch("/api/invoices").then((r) => r.json());
setInvoices(data);
}
void load();
}, []);
return (
<div>
<h1>Invoices</h1>
{invoices.map((i) => (
<p key={i.id}>{i.number}</p>
))}
</div>
);
}
20 changes: 20 additions & 0 deletions eval/fixtures/f4-typed-responses/app/OrdersPage.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>[]>([]);
useEffect(() => {
fetch("/api/orders")
.then((r) => r.json())
.then(setOrders);
}, []);
return (
<div>
<h1>Orders</h1>
{orders.map((o, i) => (
<p key={i}>{String(o.id)}</p>
))}
</div>
);
}
20 changes: 20 additions & 0 deletions eval/fixtures/f4-typed-responses/app/UsersPage.tsx
Original file line number Diff line number Diff line change
@@ -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<User[]>([]);
useEffect(() => {
axios.get<User[]>("/api/users").then((res) => setUsers(res.data));
}, []);
return (
<div>
<h1>Users</h1>
{users.map((u) => (
<p key={u.id}>{u.name}</p>
))}
</div>
);
}
35 changes: 35 additions & 0 deletions eval/fixtures/f4-typed-responses/app/openapi.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
}
11 changes: 11 additions & 0 deletions eval/fixtures/f4-typed-responses/app/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface User {
id: number;
name: string;
email: string;
}

export interface Invoice {
id: number;
number: string;
total: number;
}
27 changes: 27 additions & 0 deletions eval/fixtures/f4-typed-responses/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"failureMode": "F4",
"note": "Response-schema linking from all three sources: a generic type argument (axios.get<User[]>), 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"]
}
]
}
}
29 changes: 29 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
22 changes: 21 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
Expand All @@ -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[];
};
}

Expand All @@ -195,7 +214,8 @@ export interface CheckResult {
| "conditions"
| "externals"
| "blast"
| "coverage";
| "coverage"
| "responses";
status: CheckStatus;
detail?: string;
}
Expand Down
26 changes: 20 additions & 6 deletions packages/agent-sdk/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import {
blastRadius,
type DataSourceNode,
type ImpactNode,
type JourneyPath,
journeys,
Expand All @@ -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[];
}
Expand Down Expand Up @@ -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),
});
Expand All @@ -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: [],
});
Expand Down Expand Up @@ -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<string> {
const rendersFrom = new Map<string, string[]>();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ program
.description("Scan a React codebase and emit a lineage graph JSON")
.argument("<dir>", "directory to scan")
.option("-o, --out <file>", "output file", "ui-lineage.graph.json")
.action((dir: string, opts: { out: string }) => {
.option("--openapi <file>", "OpenAPI 3 JSON spec (relative to <dir>) 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<string, number>();
for (const node of graph.nodes) {
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@ export type EndpointResolution =
| "partial" // known shape with :param placeholders, e.g. "/api/users/:id"
| "none"; // nothing statically known ("<dynamic>")

/** 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<User[]>`), 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";
Expand All @@ -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 =
Expand Down
Loading
Loading