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.4Test coverage mapping
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.3
- **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
- **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 @@ -285,10 +285,11 @@ The heart of the project. C1 and B1 live here.
**Accept:** fixture golden: changing the c1 DataTable definition lists both page instances; changing `/api/users` lists every consumer across fixtures.
**Done:** `blastRadius(graph, target)` in core — a dependency-direction-aware reverse BFS (`dependencyOf` maps each edge kind to resource↔dependent, so a change propagates the right way regardless of edge direction; journey edges don't propagate impact). Resolves a target by node id, component name, endpoint, state name, or route path; returns `ImpactNode[]` (node, relation, distance) nearest-first, always high-confidence. Wired into the context bundle's `blastRadius` section (compact `Name@file:line` labels) and a CLI `impact <node>` command (`-d` depth cap). c1 golden `blast` block asserts both instances (d1) + both pages (d2) for the shared DataTable, and every `/api/users` consumer with an over-reach guard forbidding the invoices side. New `GoldenBlast` type + `blast` check kind in the eval harness. 5 core unit tests; eval 258/0/0, gate OK.

### [ ] 5.4 Test coverage mapping
### [x] 5.4 Test coverage mapping
**Failure modes:** F3
**Build:** scan `*.test.*` / `*.spec.*` / `__tests__`: imports + rendered components (`render(<UserList/>)`, testing-library queries) → `TestNode` + `covered-by` edges. Bundle's `tests` section lists test files for matched instances and their lineage.
**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
**Failure modes:** F4
Expand Down
8 changes: 8 additions & 0 deletions eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function Sidebar() {
return (
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { UserList } from "./UserList";

describe("UserList", () => {
it("renders the users", () => {
render(<UserList />);
expect(screen.getByText("Ada Lovelace")).toBeTruthy();
});
});
8 changes: 8 additions & 0 deletions eval/fixtures/f3-test-coverage/app/components/UserList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function UserList() {
return (
<ul>
<li>Ada Lovelace</li>
<li>Alan Turing</li>
</ul>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/f3-test-coverage/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"failureMode": "F3",
"note": "Test-coverage mapping. A co-located UserList.test.tsx renders <UserList/>, so UserList is covered-by that test; Sidebar has no test and must be reported untested. Test files never become component/instance nodes (both components have 0 instances — the test's <UserList/> does not count).",
"expect": {
"components": [
{ "name": "UserList", "instances": 0 },
{ "name": "Sidebar", "instances": 0 }
],
"coverage": [
{ "component": "UserList", "tests": ["UserList.test.tsx"] },
{ "component": "Sidebar", "untested": true }
]
}
}
38 changes: 38 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,44 @@ export function runChecks(
}
}

for (const spec of golden.expect.coverage ?? []) {
const owner = graph.nodes.find((n) => n.kind === "component" && n.name === spec.component);
const testFiles =
owner === undefined
? []
: graph.edges
.filter((e) => e.kind === "covered-by" && e.from === owner.id)
.map((e) => graph.nodes.find((n) => n.id === e.to)?.loc.file)
.filter((f): f is string => f !== undefined);
if (spec.untested === true) {
finalize(
"coverage",
`coverage:${spec.component}!covered`,
owner !== undefined && testFiles.length === 0,
spec.expectedFail,
owner === undefined
? "component not found"
: testFiles.length > 0
? `expected untested, but covered by [${testFiles.join(", ")}]`
: undefined,
);
}
for (const want of spec.tests ?? []) {
const found = testFiles.some((f) => f.includes(want));
finalize(
"coverage",
`coverage:${spec.component}<=${want}`,
owner !== undefined && found,
spec.expectedFail,
owner === undefined
? "component not found"
: found
? undefined
: `no test file matching "${want}" covers ${spec.component} (have [${testFiles.join(", ")}])`,
);
}
}

for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
const result = matchComponents(graph, {
Expand Down
15 changes: 14 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ export interface GoldenCondition {
expectedFail?: string;
}

/** A test-coverage assertion (step 5.4, failure mode F3): covered-by edges. */
export interface GoldenCoverage {
component: string;
/** Test-file path substrings that must cover this component (via covered-by edges). */
tests?: string[];
/** When true, the component must carry NO covered-by edge. */
untested?: boolean;
expectedFail?: string;
}

/** A blast-radius assertion (step 5.3, failure mode F2): reverse-dependency reach. */
export interface GoldenBlast {
/** Node to compute the blast radius from: component name, API endpoint, state name, or route path. */
Expand Down Expand Up @@ -164,6 +174,8 @@ export interface Golden {
externals?: GoldenExternal[];
/** Blast-radius reverse-dependency reach (step 5.3, F2). */
blast?: GoldenBlast[];
/** Test coverage via covered-by edges (step 5.4, F3). */
coverage?: GoldenCoverage[];
};
}

Expand All @@ -182,7 +194,8 @@ export interface CheckResult {
| "journeys"
| "conditions"
| "externals"
| "blast";
| "blast"
| "coverage";
status: CheckStatus;
detail?: string;
}
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-sdk/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ export function buildBundle(
relation: impact.relation,
distance: impact.distance,
}));

// Test coverage (5.4): tests over the matched component and its render subtree.
const subtree = componentSubtree(graph, top.component.id);
const testFiles = new Set<string>();
let topCovered = false;
for (const edge of graph.edges) {
if (edge.kind !== "covered-by" || !subtree.has(edge.from)) continue;
if (edge.from === top.component.id) topCovered = true;
const test = byId.get(edge.to);
if (test !== undefined) testFiles.add(test.loc.file);
}
bundle.tests = [...testFiles].sort().map((file) => ({ file }));
if (!topCovered) bundle.warnings.push(`untested — no test renders ${top.component.name}`);
}

if (graph.meta?.dirty === true) {
Expand All @@ -174,6 +187,34 @@ export function buildBundle(
return trimToBudget(bundle, budgetTokens);
}

/** 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[]>();
const definitionOf = new Map<string, string>();
for (const edge of graph.edges) {
if (edge.kind === "renders") {
const list = rendersFrom.get(edge.from);
if (list) list.push(edge.to);
else rendersFrom.set(edge.from, [edge.to]);
} else if (edge.kind === "instance-of") {
definitionOf.set(edge.from, edge.to);
}
}
const seen = new Set<string>([rootId]);
const stack = [rootId];
while (stack.length > 0) {
const id = stack.pop() as string;
for (const instanceId of rendersFrom.get(id) ?? []) {
const defId = definitionOf.get(instanceId);
if (defId !== undefined && !seen.has(defId)) {
seen.add(defId);
stack.push(defId);
}
}
}
return seen;
}

/** A compact, agent-readable name for one impacted node, with its source location. */
function impactLabel(impact: ImpactNode, byId: Map<string, LineageNode>): string {
const node = impact.node;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,7 @@ function dependencyOf(edge: LineageEdge): { resource: string; dependent: string
// resource --edge--> consumer: the `to` depends on the `from`.
case "provides-data": // a fed instance depends on the data source
case "writes-state": // the written state depends on its writer
case "covered-by": // a test depends on the component it renders
return { resource: edge.from, dependent: edge.to };
default:
return null;
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export type NodeKind =
| "state"
| "event"
| "route"
| "external";
| "external"
| "test";

export interface BaseNode {
/** Stable id, unique within a graph. See nodeId()/instanceId(). */
Expand Down Expand Up @@ -265,6 +266,18 @@ export interface ExternalNode extends BaseNode {
host: string;
}

/**
* A test file that exercises one or more components (TRACKER step 5.4, failure
* mode F3). Linked to the components it renders by `covered-by` edges, so the
* context bundle can name the tests that guard a change — and flag components
* that have none.
*/
export interface TestNode extends BaseNode {
kind: "test";
/** Which runner the file appears to use, by its imports/globals. */
framework: "vitest" | "jest" | "unknown";
}

export type LineageNode =
| ComponentNode
| InstanceNode
Expand All @@ -273,7 +286,8 @@ export type LineageNode =
| StateNode
| EventNode
| RouteNode
| ExternalNode;
| ExternalNode
| TestNode;

export type EdgeKind =
| "renders" // component|instance -> instance (definition-level until Phase 2.1)
Expand All @@ -288,6 +302,7 @@ export type EdgeKind =
| "navigates-to" // event -> route (handler calls navigate/router.push; Phase 3.2, B3)
| "exits-app" // event|component -> external (navigate/link to an outside URL; B9)
| "enters-at" // external -> route (a deep-link / OAuth-callback entry point; B9)
| "covered-by" // component -> test (a test file renders/exercises this component; 5.4, F3)
| "routes-to"; // route -> page component definition (its instances form the page tree)

/** A statically-detected condition guarding an edge (feature flag, role, branch). */
Expand Down
62 changes: 62 additions & 0 deletions packages/parser-react/src/coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import path from "node:path";
import { fileURLToPath } from "node:url";

import { buildBundle } from "@coderadar/agent-sdk";
import type { LineageGraph, TestNode } from "@coderadar/core";
import { describe, expect, it } from "vitest";

import { scanReact } from "./scan.js";

const fixturesDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../eval/fixtures",
);

const graph: LineageGraph = scanReact({ root: path.join(fixturesDir, "f3-test-coverage/app") });

const component = (name: string) =>
graph.nodes.find((n) => n.kind === "component" && n.name === name);
const tests = graph.nodes.filter((n): n is TestNode => n.kind === "test");
const coveredBy = (name: string) =>
graph.edges
.filter((e) => e.kind === "covered-by" && e.from === component(name)?.id)
.map((e) => graph.nodes.find((n) => n.id === e.to))
.filter((n): n is TestNode => n?.kind === "test");

describe("test-coverage mapping (TRACKER 5.4)", () => {
it("emits a TestNode for a test file that renders a component", () => {
expect(tests.map((t) => t.name)).toContain("UserList.test.tsx");
expect(tests.find((t) => t.name === "UserList.test.tsx")?.framework).toBe("vitest");
});

it("links the rendered component to its test via covered-by", () => {
const covering = coveredBy("UserList");
expect(covering.map((t) => t.name)).toEqual(["UserList.test.tsx"]);
});

it("leaves a component with no test uncovered", () => {
expect(coveredBy("Sidebar")).toHaveLength(0);
});

it("does not turn test files into component or instance nodes", () => {
// The test renders <UserList/>, but that must not materialize an instance.
const instances = graph.nodes.filter((n) => n.kind === "instance");
expect(instances).toHaveLength(0);
// No component named after the test's describe/it callbacks.
expect(component("UserList.test")).toBeUndefined();
});

it("surfaces the covering test in the context bundle", () => {
const bundle = buildBundle(graph, { text: "Ada Lovelace" }, { budgetTokens: 8000 });
expect(bundle.match[0]?.component).toBe("UserList");
expect(bundle.tests.map((t) => t.file)).toContain("components/UserList.test.tsx");
expect(bundle.warnings.some((w) => w.startsWith("untested"))).toBe(false);
});

it("warns when the matched component has no test", () => {
const bundle = buildBundle(graph, { text: "Dashboard Settings" }, { budgetTokens: 8000 });
expect(bundle.match[0]?.component).toBe("Sidebar");
expect(bundle.tests).toHaveLength(0);
expect(bundle.warnings.some((w) => w.startsWith("untested"))).toBe(true);
});
});
6 changes: 6 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js";
import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js";
import { detectRoutes } from "./routes.js";
import { detectTests, isTestFile } from "./tests.js";
import { detectWrappers, type WrapperRegistry } from "./wrappers.js";

export interface ScanOptions {
Expand Down Expand Up @@ -172,6 +173,9 @@ export function scanReact(options: ScanOptions): LineageGraph {

for (const sourceFile of project.getSourceFiles()) {
const file = toPosix(path.relative(root, sourceFile.getFilePath()));
// Test files are swept separately (5.4) — they exercise components, they
// don't define the app's UI, so they must not produce component/hook nodes.
if (isTestFile(file)) continue;
for (const decl of collectDeclarations(sourceFile, file)) {
const isComponent = COMPONENT_NAME.test(decl.name) && returnsJsx(decl.fn);
const isHook = HOOK_NAME.test(decl.name);
Expand Down Expand Up @@ -238,6 +242,8 @@ export function scanReact(options: ScanOptions): LineageGraph {
handlerExprs,
root,
);
// Tests last: components must exist before we can attach coverage to them.
detectTests(project, root, nodes, addEdge);

return {
version: 2,
Expand Down
Loading
Loading