diff --git a/TRACKER.md b/TRACKER.md index ab1c6dc..c8ad723 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 5 — Context bundle & agent interface -- **Next step:** 5.4 — Test 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.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 - **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 @@ -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 ` 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()`, 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 diff --git a/eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx b/eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx new file mode 100644 index 0000000..f9aabb1 --- /dev/null +++ b/eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx @@ -0,0 +1,8 @@ +export function Sidebar() { + return ( + + ); +} diff --git a/eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx b/eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx new file mode 100644 index 0000000..6191c8b --- /dev/null +++ b/eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx @@ -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(); + expect(screen.getByText("Ada Lovelace")).toBeTruthy(); + }); +}); diff --git a/eval/fixtures/f3-test-coverage/app/components/UserList.tsx b/eval/fixtures/f3-test-coverage/app/components/UserList.tsx new file mode 100644 index 0000000..df795d5 --- /dev/null +++ b/eval/fixtures/f3-test-coverage/app/components/UserList.tsx @@ -0,0 +1,8 @@ +export function UserList() { + return ( +
    +
  • Ada Lovelace
  • +
  • Alan Turing
  • +
+ ); +} diff --git a/eval/fixtures/f3-test-coverage/golden.json b/eval/fixtures/f3-test-coverage/golden.json new file mode 100644 index 0000000..730465d --- /dev/null +++ b/eval/fixtures/f3-test-coverage/golden.json @@ -0,0 +1,14 @@ +{ + "failureMode": "F3", + "note": "Test-coverage mapping. A co-located UserList.test.tsx renders , 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 does not count).", + "expect": { + "components": [ + { "name": "UserList", "instances": 0 }, + { "name": "Sidebar", "instances": 0 } + ], + "coverage": [ + { "component": "UserList", "tests": ["UserList.test.tsx"] }, + { "component": "Sidebar", "untested": true } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index e2b4d22..b57e92c 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -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, { diff --git a/eval/src/golden.ts b/eval/src/golden.ts index acb7f8c..1f633a1 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -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. */ @@ -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[]; }; } @@ -182,7 +194,8 @@ export interface CheckResult { | "journeys" | "conditions" | "externals" - | "blast"; + | "blast" + | "coverage"; status: CheckStatus; detail?: string; } diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts index fd348a9..46b16b7 100644 --- a/packages/agent-sdk/src/bundle.ts +++ b/packages/agent-sdk/src/bundle.ts @@ -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(); + 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) { @@ -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 { + const rendersFrom = new Map(); + const definitionOf = new Map(); + 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([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 { const node = impact.node; diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index e22ae0e..addd2af 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -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; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 18670eb..450ad59 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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(). */ @@ -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 @@ -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) @@ -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). */ diff --git a/packages/parser-react/src/coverage.test.ts b/packages/parser-react/src/coverage.test.ts new file mode 100644 index 0000000..6cee865 --- /dev/null +++ b/packages/parser-react/src/coverage.test.ts @@ -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 , 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); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index e0fe31f..4fa02bf 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -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 { @@ -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); @@ -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, diff --git a/packages/parser-react/src/tests.ts b/packages/parser-react/src/tests.ts new file mode 100644 index 0000000..7252a15 --- /dev/null +++ b/packages/parser-react/src/tests.ts @@ -0,0 +1,111 @@ +/** + * Test-coverage mapping (TRACKER step 5.4, failure mode F3). + * + * Test files (`*.test.*`, `*.spec.*`, or under `__tests__/`) are excluded from + * the component scan, then swept here: every component a test renders + * (`render()`, a JSX tag) or imports becomes a `covered-by` edge from + * the component to a `TestNode`. The context bundle reads these to name the tests + * guarding a change — and to flag components that have none. + */ + +import path from "node:path"; + +import { type LineageEdge, type LineageNode, nodeId, type TestNode } from "@coderadar/core"; +import { type Project, type SourceFile, SyntaxKind } from "ts-morph"; + +const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)__tests__\//; +const COMPONENT_NAME = /^[A-Z]/; + +/** Whether a repo-relative path is a test file (excluded from the component scan). */ +export function isTestFile(file: string): boolean { + return TEST_FILE.test(file); +} + +function toPosix(p: string): string { + return p.split(path.sep).join("/"); +} + +export function detectTests( + project: Project, + root: string, + nodes: Map, + addEdge: (edge: LineageEdge) => void, +): void { + // Fallback index for JSX tags whose import we can't resolve: name → component ids. + const byName = new Map(); + for (const node of nodes.values()) { + if (node.kind !== "component") continue; + const list = byName.get(node.name); + if (list) list.push(node.id); + else byName.set(node.name, [node.id]); + } + + for (const sourceFile of project.getSourceFiles()) { + const file = toPosix(path.relative(root, sourceFile.getFilePath())); + if (!isTestFile(file)) continue; + + // Where each imported PascalCase name resolves — the precise coverage signal. + const importedFrom = new Map(); + for (const imp of sourceFile.getImportDeclarations()) { + const src = imp.getModuleSpecifierSourceFile(); + if (src === undefined) continue; + const rel = toPosix(path.relative(root, src.getFilePath())); + const record = (name: string): void => { + if (COMPONENT_NAME.test(name)) importedFrom.set(name, rel); + }; + for (const named of imp.getNamedImports()) record(named.getNameNode().getText()); + const def = imp.getDefaultImport(); + if (def !== undefined) record(def.getText()); + } + + // Component names this test exercises: JSX tag heads plus imported identifiers. + const referenced = new Set(); + const addTag = (tag: string): void => { + const head = tag.split(".")[0] ?? ""; + if (COMPONENT_NAME.test(head)) referenced.add(head); + }; + for (const el of sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) { + addTag(el.getTagNameNode().getText()); + } + for (const el of sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) { + addTag(el.getTagNameNode().getText()); + } + for (const name of importedFrom.keys()) referenced.add(name); + + const covered = new Set(); + for (const name of referenced) { + const rel = importedFrom.get(name); + const precise = rel !== undefined ? nodeId("component", rel, name) : undefined; + if (precise !== undefined && nodes.has(precise)) { + covered.add(precise); + continue; + } + for (const id of byName.get(name) ?? []) covered.add(id); + } + if (covered.size === 0) continue; + + const id = nodeId("test", file, path.basename(file)); + if (!nodes.has(id)) { + const test: TestNode = { + id, + kind: "test", + name: path.basename(file), + loc: { file, line: 1, endLine: 1 }, + framework: detectFramework(sourceFile), + }; + nodes.set(id, test); + } + for (const componentId of covered) { + addEdge({ from: componentId, to: id, kind: "covered-by" }); + } + } +} + +function detectFramework(sourceFile: SourceFile): TestNode["framework"] { + for (const imp of sourceFile.getImportDeclarations()) { + const spec = imp.getModuleSpecifierValue(); + if (spec === "vitest") return "vitest"; + if (spec === "@jest/globals" || spec === "jest") return "jest"; + } + return "unknown"; +} diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index f21d229..8d7523f 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -93,6 +93,9 @@ }, { "$ref": "#/definitions/ExternalNode" + }, + { + "$ref": "#/definitions/TestNode" } ] }, @@ -683,6 +686,50 @@ "additionalProperties": false, "description": "A destination outside this codebase (TRACKER failure mode B9): an OAuth provider, a payment gateway, a `mailto:`/`tel:` link, or the inbound side of a deep-link/OAuth-callback route. Journeys that reach one leave the app." }, + "TestNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "test" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "framework": { + "type": "string", + "enum": [ + "vitest", + "jest", + "unknown" + ], + "description": "Which runner the file appears to use, by its imports/globals." + } + }, + "required": [ + "framework", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "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." + }, "LineageEdge": { "type": "object", "properties": { @@ -725,6 +772,7 @@ "navigates-to", "exits-app", "enters-at", + "covered-by", "routes-to" ] },