Skip to content

Commit b4825f0

Browse files
DeepCodeWorkclaude
andcommitted
feat(response): response-schema linking — generic/annotation/OpenAPI (5.5)
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<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())). 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 <noreply@anthropic.com>
1 parent dc8ae22 commit b4825f0

18 files changed

Lines changed: 635 additions & 29 deletions

File tree

TRACKER.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## Status
66

77
- **Current phase:** 5 — Context bundle & agent interface
8-
- **Next step:** 5.5Response-schema linking
9-
- **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
8+
- **Next step:** 5.6Git history
9+
- **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
1010
- **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)
1111

1212
## What CodeRadar is
@@ -291,10 +291,11 @@ The heart of the project. C1 and B1 live here.
291291
**Accept:** fixture with co-located tests: bundle names the right test files; components without tests get `warnings: ["untested"]`.
292292
**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.
293293

294-
### [ ] 5.5 Response-schema linking
294+
### [x] 5.5 Response-schema linking
295295
**Failure modes:** F4
296296
**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).
297297
**Accept:** fixture `f4-typed-responses` green for all three sources (generic, annotation, OpenAPI).
298+
**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.
298299

299300
### [ ] 5.6 Git history context
300301
**Failure modes:** F5
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useEffect, useState } from "react";
2+
3+
import type { Invoice } from "./types";
4+
5+
// Source 2 (annotation): the response type is the annotation on the variable
6+
// the fetch result lands in.
7+
export function InvoicesPage() {
8+
const [invoices, setInvoices] = useState<Invoice[]>([]);
9+
useEffect(() => {
10+
async function load() {
11+
const data: Invoice[] = await fetch("/api/invoices").then((r) => r.json());
12+
setInvoices(data);
13+
}
14+
void load();
15+
}, []);
16+
return (
17+
<div>
18+
<h1>Invoices</h1>
19+
{invoices.map((i) => (
20+
<p key={i.id}>{i.number}</p>
21+
))}
22+
</div>
23+
);
24+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useEffect, useState } from "react";
2+
3+
// Source 3 (OpenAPI): the code says nothing about the shape — the response type
4+
// is recovered from the spec by matching GET /api/orders.
5+
export function OrdersPage() {
6+
const [orders, setOrders] = useState<Record<string, unknown>[]>([]);
7+
useEffect(() => {
8+
fetch("/api/orders")
9+
.then((r) => r.json())
10+
.then(setOrders);
11+
}, []);
12+
return (
13+
<div>
14+
<h1>Orders</h1>
15+
{orders.map((o, i) => (
16+
<p key={i}>{String(o.id)}</p>
17+
))}
18+
</div>
19+
);
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import axios from "axios";
2+
import { useEffect, useState } from "react";
3+
4+
import type { User } from "./types";
5+
6+
// Source 1 (generic): the response type is the call's type argument.
7+
export function UsersPage() {
8+
const [users, setUsers] = useState<User[]>([]);
9+
useEffect(() => {
10+
axios.get<User[]>("/api/users").then((res) => setUsers(res.data));
11+
}, []);
12+
return (
13+
<div>
14+
<h1>Users</h1>
15+
{users.map((u) => (
16+
<p key={u.id}>{u.name}</p>
17+
))}
18+
</div>
19+
);
20+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"openapi": "3.0.0",
3+
"info": { "title": "Fixture API", "version": "1.0.0" },
4+
"paths": {
5+
"/api/orders": {
6+
"get": {
7+
"responses": {
8+
"200": {
9+
"description": "orders",
10+
"content": {
11+
"application/json": {
12+
"schema": {
13+
"type": "array",
14+
"items": { "$ref": "#/components/schemas/Order" }
15+
}
16+
}
17+
}
18+
}
19+
}
20+
}
21+
}
22+
},
23+
"components": {
24+
"schemas": {
25+
"Order": {
26+
"type": "object",
27+
"properties": {
28+
"id": { "type": "integer" },
29+
"status": { "type": "string" },
30+
"total": { "type": "number" }
31+
}
32+
}
33+
}
34+
}
35+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface User {
2+
id: number;
3+
name: string;
4+
email: string;
5+
}
6+
7+
export interface Invoice {
8+
id: number;
9+
number: string;
10+
total: number;
11+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"failureMode": "F4",
3+
"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[]).",
4+
"scan": { "openapi": "openapi.json" },
5+
"expect": {
6+
"responses": [
7+
{
8+
"endpoint": "/api/users",
9+
"name": "User[]",
10+
"from": "generic",
11+
"fields": ["id", "name", "email"]
12+
},
13+
{
14+
"endpoint": "/api/invoices",
15+
"name": "Invoice[]",
16+
"from": "annotation",
17+
"fields": ["id", "number", "total"]
18+
},
19+
{
20+
"endpoint": "/api/orders",
21+
"name": "Order[]",
22+
"from": "openapi",
23+
"fields": ["id", "status", "total"]
24+
}
25+
]
26+
}
27+
}

eval/src/checks.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,35 @@ export function runChecks(
351351
}
352352
}
353353

354+
for (const spec of golden.expect.responses ?? []) {
355+
const id = `response:${spec.endpoint}=>${spec.name}`;
356+
const source = graph.nodes.find(
357+
(n) =>
358+
n.kind === "data-source" &&
359+
n.endpoint === spec.endpoint &&
360+
(spec.method === undefined || n.method === spec.method),
361+
);
362+
const rt = source?.kind === "data-source" ? source.responseType : undefined;
363+
let passed = rt !== undefined && rt.name === spec.name;
364+
let detail: string | undefined;
365+
if (source === undefined) detail = `no data source for ${spec.endpoint}`;
366+
else if (rt === undefined) detail = `no response type on ${spec.endpoint}`;
367+
else if (rt.name !== spec.name) detail = `expected response ${spec.name}, got ${rt.name}`;
368+
if (passed && rt !== undefined && spec.from !== undefined && rt.source !== spec.from) {
369+
passed = false;
370+
detail = `expected source ${spec.from}, got ${rt.source}`;
371+
}
372+
if (passed && rt !== undefined && spec.fields !== undefined) {
373+
const have = new Set(rt.fields.map((f) => f.name));
374+
const missing = spec.fields.filter((f) => !have.has(f));
375+
if (missing.length > 0) {
376+
passed = false;
377+
detail = `missing fields [${missing.join(", ")}] (have [${[...have].join(", ")}])`;
378+
}
379+
}
380+
finalize("responses", id, passed, spec.expectedFail, detail);
381+
}
382+
354383
for (const query of golden.expect.queries ?? []) {
355384
const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
356385
const result = matchComponents(graph, {

eval/src/golden.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ export interface GoldenCondition {
104104
expectedFail?: string;
105105
}
106106

107+
/** A response-schema assertion (step 5.5, failure mode F4): a data source's response type. */
108+
export interface GoldenResponse {
109+
/** Endpoint the data source resolves to. */
110+
endpoint: string;
111+
/** HTTP method, to disambiguate same-endpoint sources. */
112+
method?: string;
113+
/** Expected response-type name (e.g. "User[]"). */
114+
name: string;
115+
/** Where the type must have come from. */
116+
from?: "generic" | "annotation" | "openapi";
117+
/** Field names that must appear in the response type. */
118+
fields?: string[];
119+
expectedFail?: string;
120+
}
121+
107122
/** A test-coverage assertion (step 5.4, failure mode F3): covered-by edges. */
108123
export interface GoldenCoverage {
109124
component: string;
@@ -155,6 +170,8 @@ export interface Golden {
155170
baseUrls?: string[];
156171
apiWrappers?: string[];
157172
i18n?: { localeGlobs: string[]; defaultLocale: string };
173+
/** OpenAPI spec path (relative to the app dir) for response-schema linking (5.5). */
174+
openapi?: string;
158175
};
159176
expect: {
160177
components?: GoldenComponent[];
@@ -176,6 +193,8 @@ export interface Golden {
176193
blast?: GoldenBlast[];
177194
/** Test coverage via covered-by edges (step 5.4, F3). */
178195
coverage?: GoldenCoverage[];
196+
/** Response types on data sources (step 5.5, F4). */
197+
responses?: GoldenResponse[];
179198
};
180199
}
181200

@@ -195,7 +214,8 @@ export interface CheckResult {
195214
| "conditions"
196215
| "externals"
197216
| "blast"
198-
| "coverage";
217+
| "coverage"
218+
| "responses";
199219
status: CheckStatus;
200220
detail?: string;
201221
}

packages/agent-sdk/src/bundle.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212
import {
1313
blastRadius,
14+
type DataSourceNode,
1415
type ImpactNode,
1516
type JourneyPath,
1617
journeys,
@@ -29,10 +30,17 @@ export interface BundleMatch {
2930
evidence: string[];
3031
}
3132

33+
export interface BundleDataSource {
34+
method: string | null;
35+
endpoint: string;
36+
/** Response shape (5.5, F4), when recoverable — name + one level of fields. */
37+
responseType?: { name: string; fields: { name: string; type: string }[]; source: string };
38+
}
39+
3240
export interface BundleLineageEntry {
3341
/** Component name, or `Name@file:line` for a specific instance. */
3442
target: string;
35-
dataSources: { method: string | null; endpoint: string }[];
43+
dataSources: BundleDataSource[];
3644
state: string[];
3745
events: string[];
3846
}
@@ -136,10 +144,7 @@ export function buildBundle(
136144
if (definitionLineage !== undefined) {
137145
bundle.lineage.push({
138146
target: top.component.name,
139-
dataSources: definitionLineage.dataSources.map((d) => ({
140-
method: d.method,
141-
endpoint: d.endpoint,
142-
})),
147+
dataSources: definitionLineage.dataSources.map(bundleDataSource),
143148
state: definitionLineage.state.map((s) => s.name),
144149
events: definitionLineage.events.map((e) => e.event),
145150
});
@@ -149,7 +154,7 @@ export function buildBundle(
149154
if (instLineage === undefined || instLineage.dataSources.length === 0) continue;
150155
bundle.lineage.push({
151156
target: `${top.component.name}@${instance.loc.file}:${instance.loc.line}`,
152-
dataSources: instLineage.dataSources.map((d) => ({ method: d.method, endpoint: d.endpoint })),
157+
dataSources: instLineage.dataSources.map(bundleDataSource),
153158
state: [],
154159
events: [],
155160
});
@@ -187,6 +192,15 @@ export function buildBundle(
187192
return trimToBudget(bundle, budgetTokens);
188193
}
189194

195+
/** Project a data-source node into the bundle shape, carrying its response type when present. */
196+
function bundleDataSource(d: DataSourceNode): BundleDataSource {
197+
return {
198+
method: d.method,
199+
endpoint: d.endpoint,
200+
...(d.responseType !== undefined ? { responseType: d.responseType } : {}),
201+
};
202+
}
203+
190204
/** Component ids in the render subtree of `rootId` (itself plus renders → instance-of descendants). */
191205
function componentSubtree(graph: LineageGraph, rootId: string): Set<string> {
192206
const rendersFrom = new Map<string, string[]>();

0 commit comments

Comments
 (0)