From 7c692cc19414561bc393a799763995aba9b33b47 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 11:58:57 +0530 Subject: [PATCH] =?UTF-8?q?feat(impact):=20blast=20radius=20=E2=80=94=20re?= =?UTF-8?q?verse-dependency=20traversal=20(5.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blastRadius(graph, target) in core: a dependency-direction-aware reverse BFS. dependencyOf() maps each edge kind to resource↔dependent so a change propagates the correct way regardless of edge direction (fetches-from and provides-data both reach a data source's consumers); 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 and a new CLI `impact ` command (--depth cap). c1 golden gains a `blast` block: both DataTable instances (d1) + both pages (d2) for the shared definition, and every /api/users consumer with an over-reach guard forbidding the invoices side. New GoldenBlast type + `blast` check kind in the harness. 5 core unit tests; eval 258/0/0, gate OK. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 7 +- eval/fixtures/c1-shared-datatable/golden.json | 21 +++ eval/src/checks.ts | 50 ++++++++ eval/src/golden.ts | 24 +++- packages/agent-sdk/src/bundle.ts | 30 +++++ packages/cli/README.md | 12 ++ packages/cli/src/index.ts | 46 +++++++ packages/core/src/query.test.ts | 63 ++++++++- packages/core/src/query.ts | 120 ++++++++++++++++++ 9 files changed, 368 insertions(+), 5 deletions(-) diff --git a/TRACKER.md b/TRACKER.md index 50eb365..ab1c6dc 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 5 — Context bundle & agent interface -- **Next step:** 5.3 — Blast 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.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 - **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 @@ -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 `. **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 **Failure modes:** F3 diff --git a/eval/fixtures/c1-shared-datatable/golden.json b/eval/fixtures/c1-shared-datatable/golden.json index 5704b1d..4f282ec 100644 --- a/eval/fixtures/c1-shared-datatable/golden.json +++ b/eval/fixtures/c1-shared-datatable/golden.json @@ -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"] + } ] } } diff --git a/eval/src/checks.ts b/eval/src/checks.ts index 93054f7..e2b4d22 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -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"; @@ -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, { diff --git a/eval/src/golden.ts b/eval/src/golden.ts index e223a6b..acb7f8c 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -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"; @@ -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[]; }; } @@ -160,7 +181,8 @@ export interface CheckResult { | "effects" | "journeys" | "conditions" - | "externals"; + | "externals" + | "blast"; status: CheckStatus; detail?: string; } diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts index 6c6fad3..fd348a9 100644 --- a/packages/agent-sdk/src/bundle.ts +++ b/packages/agent-sdk/src/bundle.ts @@ -10,9 +10,12 @@ * trim is recorded in `warnings`. */ import { + blastRadius, + type ImpactNode, type JourneyPath, journeys, type LineageGraph, + type LineageNode, traceLineage, } from "@coderadar/core"; @@ -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(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) { @@ -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 { + 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; diff --git a/packages/cli/README.md b/packages/cli/README.md index 1c46933..073dbae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 ` 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: ``` diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d72bc86..aa2c97c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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, @@ -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 id, component name, API endpoint, state name, or route path") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-d, --depth ", "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") @@ -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): void { const match = candidate.value; console.log( diff --git a/packages/core/src/query.test.ts b/packages/core/src/query.test.ts index bce0e9c..265ebd6 100644 --- a/packages/core/src/query.test.ts +++ b/packages/core/src/query.test.ts @@ -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, @@ -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"); + }); +}); diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 32bd060..e22ae0e 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -19,6 +19,7 @@ import type { ComponentNode, DataSourceNode, EdgeCondition, + EdgeKind, EventNode, Evidence, InstanceNode, @@ -798,3 +799,122 @@ function groupInstances(graph: LineageGraph): Map { } return byDefinition; } + +/** One node affected by changing the blast-radius target, with the hop that reaches it. */ +export interface ImpactNode { + node: LineageNode; + /** The edge kind connecting this node to its (closer) dependency on the path. */ + relation: EdgeKind; + /** Reverse-dependency hops from the target (1 = a direct dependent). */ + distance: number; +} + +export interface BlastRadiusOptions { + /** Maximum dependency hops to follow. Default Infinity (whole component). */ + depth?: number; +} + +/** + * For each edge, decide which endpoint is the *resource* and which *depends on* + * it, so we can walk "who is affected if I change X" regardless of edge + * direction. Journey edges (handles/triggers/navigates-to/exits-app/enters-at) + * are behaviour, not data/render dependencies, so they don't propagate impact. + */ +function dependencyOf(edge: LineageEdge): { resource: string; dependent: string } | null { + switch (edge.kind) { + // consumer --edge--> resource: the `from` depends on the `to`. + case "instance-of": // instance depends on its definition + case "renders": // a parent's render depends on the child instance + case "fetches-from": // a component/hook depends on the data source it calls + case "reads-state": // a reader depends on the state slice + case "uses-hook": // a component depends on the hook + case "routes-to": // a route depends on the page it renders + return { resource: edge.to, dependent: edge.from }; + // 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 + return { resource: edge.from, dependent: edge.to }; + default: + return null; + } +} + +/** + * Blast radius (TRACKER step 5.3, failure mode F2): everything that *depends on* + * a node, so a change to it can be reviewed for impact. A reverse-dependency BFS + * — changing a component definition surfaces its instances and the pages that + * render them; changing a data source surfaces every consumer, the instances it + * feeds, and the state it writes (and their readers). + * + * `target` is a node id, a component name, a data-source endpoint, a state name, + * or a route path. Returns one candidate whose value is the affected nodes, + * ordered nearest-first. + */ +export function blastRadius( + graph: LineageGraph, + target: string, + options: BlastRadiusOptions = {}, +): QueryResult { + const depth = options.depth ?? Number.POSITIVE_INFINITY; + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const start = + byId.get(target) ?? + graph.nodes.find((n) => n.kind === "component" && n.name === target) ?? + graph.nodes.find((n) => n.kind === "data-source" && n.endpoint === target) ?? + graph.nodes.find((n) => n.kind === "state" && n.name === target) ?? + graph.nodes.find((n) => n.kind === "route" && n.path === target); + if (start === undefined) return declined("not-found"); + + // resource id -> its direct dependents (and the edge that makes them depend). + const dependents = new Map(); + for (const edge of graph.edges) { + const dep = dependencyOf(edge); + if (dep === null) continue; + const list = dependents.get(dep.resource); + if (list) list.push({ id: dep.dependent, relation: edge.kind }); + else dependents.set(dep.resource, [{ id: dep.dependent, relation: edge.kind }]); + } + + const seen = new Set([start.id]); + const queue: { id: string; distance: number }[] = [{ id: start.id, distance: 0 }]; + const impacts: ImpactNode[] = []; + while (queue.length > 0) { + const { id, distance } = queue.shift() as { id: string; distance: number }; + if (distance >= depth) continue; + for (const { id: dependentId, relation } of dependents.get(id) ?? []) { + if (seen.has(dependentId)) continue; + seen.add(dependentId); + const node = byId.get(dependentId); + if (node === undefined) continue; + impacts.push({ node, relation, distance: distance + 1 }); + queue.push({ id: dependentId, distance: distance + 1 }); + } + } + impacts.sort((a, b) => a.distance - b.distance); + + const evidence: Evidence[] = [ + { + kind: "edge-chain", + detail: `${impacts.length} node(s) depend on ${start.kind} ${labelOf(start)}`, + loc: start.loc, + }, + ]; + return ok([{ value: impacts, confidence: confidenceFromScore(1), evidence }]); +} + +function labelOf(node: LineageNode): string { + switch (node.kind) { + case "component": + case "hook": + case "state": + return node.name; + case "event": + return node.handler ?? node.event; + case "data-source": + return node.endpoint; + case "route": + return node.path; + default: + return node.id; + } +}