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.3Blast radius (reverse traversal)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.2
- **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
- **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 @@ -279,10 +279,11 @@ The heart of the project. C1 and B1 live here.
**Build:** `ContextBundle` type + JSON Schema (committed, drift-gated): sections `match` (instances + evidence), `lineage` (per-instance sources with patterns/methods), `journeys` (slice around the match, depth 2 default), `blastRadius`, `tests`, `history`, `warnings` (staleness, incomplete flags). Budgeter: `budgetTokens` param; sections trimmed in fixed priority order (match > lineage > blastRadius > journeys > tests > history), each trim recorded in `warnings`.
**Accept:** golden bundles for 5 ticket evals; every bundle ≤ budget under a tokenizer test at budgets 2k/4k/8k; trim order unit-tested.

### [ ] 5.3 Blast radius — reverse traversal
### [x] 5.3 Blast radius — reverse traversal
**Failure modes:** F2
**Build:** reverse adjacency in core: `blastRadius(nodeId)` → all instances rendering this definition, all consumers of this data source/endpoint, all readers of this state slice, journeys passing through — each with distance. CLI `coderadar impact <node>`.
**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
**Failure modes:** F3
Expand Down
21 changes: 21 additions & 0 deletions eval/fixtures/c1-shared-datatable/golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@
{ "terms": ["All Users"], "status": "ok", "top": "UsersPage" },
{ "terms": ["All Invoices"], "status": "ok", "top": "InvoicesPage" },
{ "terms": ["No records found"], "status": "ok", "top": "DataTable" }
],
"blast": [
{
"node": "DataTable",
"note": "Changing the shared DataTable definition surfaces both page instances (distance 1) and the pages that render them (distance 2).",
"expect": [
{ "node": "DataTable", "kind": "instance", "at": "pages/UsersPage.tsx", "distance": 1 },
{ "node": "DataTable", "kind": "instance", "at": "pages/InvoicesPage.tsx", "distance": 1 },
{ "node": "UsersPage", "kind": "component", "distance": 2 },
{ "node": "InvoicesPage", "kind": "component", "distance": 2 }
]
},
{
"node": "/api/users",
"note": "Changing the users API surfaces every consumer — the fetching page and the table instance it feeds — but must NOT reach the invoices page or its API.",
"expect": [
{ "node": "UsersPage", "kind": "component", "distance": 1 },
{ "node": "DataTable", "kind": "instance", "at": "pages/UsersPage.tsx", "distance": 1 }
],
"forbidden": ["InvoicesPage", "/api/invoices"]
}
]
}
}
50 changes: 50 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/** Run one fixture's golden checks against its scanned graph. */

import {
blastRadius,
journeys,
type LineageGraph,
type LineageNode,
matchComponents,
traceLineage,
} from "@coderadar/core";
Expand Down Expand Up @@ -263,6 +265,54 @@ export function runChecks(
);
}

const impactName = (n: LineageNode): string =>
n.kind === "instance"
? (graph.nodes.find((d) => d.id === n.definitionId)?.name ?? "")
: n.kind === "data-source"
? n.endpoint
: n.kind === "route"
? n.path
: n.kind === "event"
? (n.handler ?? n.event)
: n.name;

for (const spec of golden.expect.blast ?? []) {
const result = blastRadius(graph, spec.node);
const impacts = result.candidates[0]?.value ?? [];
for (const want of spec.expect) {
const id = `blast:${spec.node}=>${want.node}${want.at ? `@${want.at}` : ""}${want.distance !== undefined ? `[${want.distance}]` : ""}`;
const found = impacts.find(
(im) =>
impactName(im.node) === want.node &&
(want.kind === undefined || im.node.kind === want.kind) &&
(want.at === undefined || im.node.loc.file.includes(want.at)) &&
(want.distance === undefined || im.distance === want.distance),
);
finalize(
"blast",
id,
result.status === "ok" && found !== undefined,
spec.expectedFail,
result.status !== "ok"
? `blastRadius(${spec.node}) returned ${result.status}`
: found === undefined
? `no impact ${want.node}${want.at ? ` at ${want.at}` : ""}${want.distance !== undefined ? ` at distance ${want.distance}` : ""}`
: undefined,
);
}
// Over-reach guard: forbidden nodes never gate as xfail — a leak is always a failure.
for (const forbidden of spec.forbidden ?? []) {
const leaked = impacts.some((im) => impactName(im.node) === forbidden);
finalize(
"blast",
`blast:${spec.node}!=>${forbidden}`,
!leaked,
undefined,
leaked ? `over-reach: ${forbidden} appeared in the blast radius of ${spec.node}` : undefined,
);
}
}

for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
const result = matchComponents(graph, {
Expand Down
24 changes: 23 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ export interface GoldenCondition {
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. */
node: string;
/** Impacted nodes that must appear. */
expect: Array<{
/** Component/definition name, endpoint, state name, or route path of the impacted node. */
node: string;
kind?: "component" | "instance" | "data-source" | "state" | "route" | "hook" | "event";
/** Substring the impacted node's file path must contain (disambiguates instances). */
at?: string;
/** Exact reverse-dependency distance, when asserted (1 = direct dependent). */
distance?: number;
}>;
/** Definition/endpoint names that must NOT appear in the blast radius (over-reach guard). */
forbidden?: string[];
expectedFail?: string;
}

/** A cross-app hop (step B9): an exits-app or enters-at edge. */
export interface GoldenExternal {
kind: "exits" | "enters";
Expand Down Expand Up @@ -143,6 +162,8 @@ export interface Golden {
conditions?: GoldenCondition[];
/** Cross-app hops (B9): exits-app / enters-at edges. */
externals?: GoldenExternal[];
/** Blast-radius reverse-dependency reach (step 5.3, F2). */
blast?: GoldenBlast[];
};
}

Expand All @@ -160,7 +181,8 @@ export interface CheckResult {
| "effects"
| "journeys"
| "conditions"
| "externals";
| "externals"
| "blast";
status: CheckStatus;
detail?: string;
}
Expand Down
30 changes: 30 additions & 0 deletions packages/agent-sdk/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
* trim is recorded in `warnings`.
*/
import {
blastRadius,
type ImpactNode,
type JourneyPath,
journeys,
type LineageGraph,
type LineageNode,
traceLineage,
} from "@coderadar/core";

Expand Down Expand Up @@ -152,6 +155,14 @@ export function buildBundle(
});
}
bundle.journeys = journeys(graph, top.component.name, { depth }).candidates[0]?.value ?? [];

const impacts = blastRadius(graph, top.component.id).candidates[0]?.value ?? [];
const byId = new Map<string, LineageNode>(graph.nodes.map((n) => [n.id, n]));
bundle.blastRadius = impacts.map((impact) => ({
node: impactLabel(impact, byId),
relation: impact.relation,
distance: impact.distance,
}));
}

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

/** 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;
const where = `${node.loc.file}:${node.loc.line}`;
if (node.kind === "instance") {
const defName = byId.get(node.definitionId)?.name ?? "?";
return `${defName}@${where}`;
}
const name =
node.kind === "data-source"
? node.endpoint
: node.kind === "route"
? node.path
: node.kind === "event"
? (node.handler ?? node.event)
: node.name;
return `${node.kind} ${name} (${where})`;
}

function trimToBudget(bundle: ContextBundle, budgetTokens: number): ContextBundle {
for (const section of TRIM_ORDER) {
if (estimateTokens(bundle) <= budgetTokens) break;
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,21 @@ 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
ui-lineage journeys /users -g app.graph.json # user-journey paths from a page
ui-lineage impact /api/users -g app.graph.json # blast radius: everything that depends on a node
ui-lineage resolve "cart total is wrong" # classify a ticket, then match it to components
ui-lineage bundle "cart total is wrong" -b 4000 # a budgeted context bundle (JSON) for an agent
ui-lineage correct BillingCard "amount owed" # record a correction for next time
```

`impact <node>` takes a component name, an API endpoint, a state name, or a route
path, and lists every node that depends on it (reverse traversal), each indented by
its distance — so a change can be reviewed for what it might break:

```
[instance-of] instance DataTable (pages/UsersPage.tsx:17)
[renders] component UsersPage (pages/UsersPage.tsx:5)
```

`journeys` reads left-to-right, with `↩ cycle` where a list ⇄ detail loop closes:

```
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import fs from "node:fs";
import path from "node:path";

import {
blastRadius,
type Candidate,
collectGraphMeta,
type ComponentMatch,
type ImpactNode,
type JourneyPath,
journeys,
type LineageGraph,
Expand Down Expand Up @@ -223,6 +225,34 @@ program
console.log(JSON.stringify(bundle, null, 2));
});

program
.command("impact")
.description("Blast radius: everything that depends on a node (reverse traversal)")
.argument("<node>", "node id, component name, API endpoint, state name, or route path")
.option("-g, --graph <file>", "graph file", "ui-lineage.graph.json")
.option("-d, --depth <n>", "max dependency hops", "0")
.action((node: string, opts: { graph: string; depth: string }) => {
const graph = loadGraph(opts.graph);
const depth = Number.parseInt(opts.depth, 10);
const result = blastRadius(
graph,
node,
depth > 0 ? { depth } : {},
);
if (result.status === "declined" || result.candidates[0] === undefined) {
console.error(`Node not found: ${node} (${result.declineReason ?? "no result"}).`);
process.exitCode = 1;
return;
}
const impacts = result.candidates[0].value;
if (impacts.length === 0) {
console.log(`Nothing depends on ${node}.`);
return;
}
console.log(`${impacts.length} node(s) affected by changing ${node}:\n`);
for (const impact of impacts) printImpact(impact);
});

program
.command("correct")
.description("Record that some on-screen text means a component — feeds future `find` results")
Expand Down Expand Up @@ -255,6 +285,22 @@ function printJourneyPath(path: JourneyPath): void {
console.log(` ${parts.join(" ")}${tag}`);
}

function printImpact(impact: ImpactNode): void {
const node = impact.node;
const name =
node.kind === "data-source"
? node.endpoint
: node.kind === "route"
? node.path
: node.kind === "event"
? (node.handler ?? node.event)
: node.name;
const indent = " ".repeat(impact.distance);
console.log(
`${indent}[${impact.relation}] ${node.kind} ${name} (${node.loc.file}:${node.loc.line})`,
);
}

function printMatchCandidate(candidate: Candidate<ComponentMatch>): void {
const match = candidate.value;
console.log(
Expand Down
63 changes: 62 additions & 1 deletion packages/core/src/query.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { matchComponentsByText, traceLineage } from "./query.js";
import { blastRadius, matchComponentsByText, traceLineage } from "./query.js";
import { confidenceFromScore } from "./result.js";
import {
type ComponentNode,
Expand Down Expand Up @@ -175,3 +175,64 @@ describe("traceLineage", () => {
expect(result.status).toBe("ok");
});
});

describe("blastRadius", () => {
// The c1 shape: one shared DataTable, two pages, two APIs (mirrors the fixture).
const table = component("DataTable.tsx", "DataTable", ["No records found"]);
const usersPage = component("UsersPage.tsx", "UsersPage", ["All Users"]);
const invoicesPage = component("InvoicesPage.tsx", "InvoicesPage", ["All Invoices"]);
const usersTable = instance("UsersPage.tsx", 17, table);
const invoicesTable = instance("InvoicesPage.tsx", 17, table);
const usersApi = dataSource("UsersPage.tsx", "/api/users");
const invoicesApi = dataSource("InvoicesPage.tsx", "/api/invoices");

const g = graph(
[table, usersPage, invoicesPage, usersTable, invoicesTable, usersApi, invoicesApi],
[
{ from: usersPage.id, to: usersTable.id, kind: "renders" },
{ from: usersTable.id, to: table.id, kind: "instance-of" },
{ from: invoicesPage.id, to: invoicesTable.id, kind: "renders" },
{ from: invoicesTable.id, to: table.id, kind: "instance-of" },
{ from: usersPage.id, to: usersApi.id, kind: "fetches-from" },
{ from: invoicesPage.id, to: invoicesApi.id, kind: "fetches-from" },
{ from: usersApi.id, to: usersTable.id, kind: "provides-data" },
{ from: invoicesApi.id, to: invoicesTable.id, kind: "provides-data" },
],
);

it("declines not-found for unknown targets", () => {
expect(blastRadius(g, "Nope").declineReason).toBe("not-found");
});

it("finds both instances (d1) and both pages (d2) for a shared definition", () => {
const impacts = blastRadius(g, "DataTable").candidates[0]?.value ?? [];
const ids = impacts.map((i) => ({ id: i.node.id, distance: i.distance }));
expect(ids).toContainEqual({ id: usersTable.id, distance: 1 });
expect(ids).toContainEqual({ id: invoicesTable.id, distance: 1 });
expect(ids).toContainEqual({ id: usersPage.id, distance: 2 });
expect(ids).toContainEqual({ id: invoicesPage.id, distance: 2 });
});

it("resolves a data source by endpoint and lists only its consumers", () => {
const impacts = blastRadius(g, "/api/users").candidates[0]?.value ?? [];
const ids = impacts.map((i) => i.node.id);
expect(ids).toContain(usersPage.id); // fetcher
expect(ids).toContain(usersTable.id); // fed instance
// Over-reach guard: nothing from the invoices side leaks in.
expect(ids).not.toContain(invoicesPage.id);
expect(ids).not.toContain(invoicesApi.id);
expect(ids).not.toContain(invoicesTable.id);
});

it("honors the depth cap", () => {
const shallow = blastRadius(g, "DataTable", { depth: 1 }).candidates[0]?.value ?? [];
expect(shallow.every((i) => i.distance <= 1)).toBe(true);
expect(shallow.some((i) => i.node.id === usersPage.id)).toBe(false);
});

it("returns a deterministic, high-confidence result", () => {
const result = blastRadius(g, "DataTable");
expect(result.status).toBe("ok");
expect(result.candidates[0]?.confidence.level).toBe("high");
});
});
Loading
Loading