diff --git a/.gitignore b/.gitignore index b709cfe..5e5130a 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,6 @@ eval/scorecard.json # ui-lineage scanned graphs *.graph.json + +# ui-lineage SHA-keyed graph store (6.4) +.coderadar/ diff --git a/TRACKER.md b/TRACKER.md index bee08fb..ca6277e 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 6 — Lifecycle, scale, hardening (6F field-hardening done bar the data-blocked 6F.6 detection half) -- **Next step:** 6.4 version skew, then 6.1 incremental re-scan, 6.2 scale (bench repo). 6.3 determinism + 6.5 generated-classification/PII landed first (6.3 de-risks the rest via a two-run byte-identity gate; 6.5 closes the D5/G7 gate criteria). Publish 0.4.1 to the tester still pending (needs npm creds). -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half), **6.3, 6.5** · **0.4.1 prepared** (all packages bumped, changelog + version strings, `npm pack` → ui-lineage-0.4.1.tgz verified: both bins, no @coderadar leak; publish to npm pending). 0.4.0 tagged/merged earlier. **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run. +- **Next step:** 6.1 incremental re-scan, then 6.2 scale (bench repo) — the last two before Gate 6 is fully stamped. 6.3/6.4/6.5 landed (determinism gate, version-skew/rename tracking, generated-classification/PII). Publish 0.4.1 to the tester still pending (needs npm creds). +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half), **6.3, 6.4, 6.5** · **0.4.1 prepared** (all packages bumped, changelog + version strings, `npm pack` → ui-lineage-0.4.1.tgz verified: both bins, no @coderadar leak; publish to npm pending). 0.4.0 tagged/merged earlier. **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run. - **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) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP) ## What CodeRadar is @@ -347,10 +347,30 @@ accept criterion lands with 6.2. Vision/OCR image-hash caching deferred — the eval path exercises no live OCR, so it isn't a determinism risk today; revisit if 6.4/vision work reintroduces it. -### [ ] 6.4 Version skew & rename tracking +### [x] 6.4 Version skew & rename tracking **Failure modes:** G3, A11 **Build:** graph store keyed by SHA (`.coderadar/graphs/.json` + `latest` pointer); `resolveContext` accepts `graphVersion`; cross-version diff maps renamed/moved definitions (same structure+text signature, different name/path) → bundle warning: "matched `InvoiceCard` in prod graph; renamed `BillingCard` on main". **Accept:** fixture pair (pre/post rename): query against old graph + current code yields the rename warning with the new name. +**Done:** new core `diffRenames(fromGraph, toGraph)` pairs a definition that is +gone by identity (name+file) in the newer graph with a same-**body-signature** +definition that is new there — signature = structural fingerprint + normalized +rendered text + props + rendered-children, all stable across a rename or file +move. Confident 1:1 only: a signature unique among both the gone and the arrived +definitions; generic empty bodies and ambiguous many-to-many sets are skipped, so +no false renames. SHA-keyed store in core storage (`saveGraphToStore` / +`loadGraphFromStore` / `graphStoreDir`): `.coderadar/graphs/.json` (or +`working` outside git) + a `latest` pointer. `buildBundle` gains a `currentGraph` +option — when the matched definition was renamed/moved there it emits +`version skew — matched \`InvoiceCard\` (…); renamed \`BillingCard\` (…) in the +current graph`. CLI: `scan --store` writes the store; `bundle --against ` +loads the current graph from it (`resolveContext`/version selection surfaced at +the store boundary). Fixture pair `g3-version-skew/{old,new}` (InvoiceCard.tsx → +BillingCard.tsx, renamed **and** moved, identical body); its golden validates the +post-rename graph directly, the two-graph diff is covered by unit tests. Verified +end-to-end via the CLI: an old-graph ticket against `--against latest` warns with +the new name+file. 7 core + 4 store + 2 parser-react (real scan) + 3 agent-sdk +tests. `.coderadar/` gitignored. eval 317/0/0/0, determinism 1.000, all metrics +1.000; typecheck + lint clean. ### [x] 6.5 Generated/vendored classification & PII policy **Failure modes:** D5, G7 diff --git a/eval/fixtures/g3-version-skew/golden.json b/eval/fixtures/g3-version-skew/golden.json new file mode 100644 index 0000000..e1b5d50 --- /dev/null +++ b/eval/fixtures/g3-version-skew/golden.json @@ -0,0 +1,10 @@ +{ + "failureMode": "G3", + "app": "./new", + "note": "Version skew & rename tracking (6.4): a definition renamed InvoiceCard -> BillingCard and moved InvoiceCard.tsx -> BillingCard.tsx between two graph versions. This fixture's golden validates the post-rename ('current') graph directly — BillingCard exists, traces /api/invoices, and matches its rendered text. The cross-version rename DIFF (old graph + new graph -> 'renamed BillingCard' warning) is a two-graph property, exercised by unit tests in core (diffRenames), parser-react (real scan of ./old and ./new), and agent-sdk (buildBundle currentGraph warning).", + "expect": { + "components": [{ "name": "BillingCard", "instances": 0 }], + "attributions": [{ "component": "BillingCard", "endpoints": ["/api/invoices"] }], + "queries": [{ "terms": ["Invoice summary"], "status": "ok", "top": "BillingCard" }] + } +} diff --git a/eval/fixtures/g3-version-skew/new/BillingCard.tsx b/eval/fixtures/g3-version-skew/new/BillingCard.tsx new file mode 100644 index 0000000..c62ee1d --- /dev/null +++ b/eval/fixtures/g3-version-skew/new/BillingCard.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +// Post-rename version (the "current"/main graph). Same body as +// ../old/InvoiceCard.tsx, but the component is renamed InvoiceCard -> BillingCard +// and the file moved InvoiceCard.tsx -> BillingCard.tsx. A ticket resolved +// against the old graph must warn that the live definition is now BillingCard. +export function BillingCard() { + const [rows, setRows] = useState([]); + + useEffect(() => { + fetch("/api/invoices") + .then((res) => res.json()) + .then(setRows); + }, []); + + return ( +
+

Invoice summary

+

{rows.length} line items

+
+ ); +} diff --git a/eval/fixtures/g3-version-skew/old/InvoiceCard.tsx b/eval/fixtures/g3-version-skew/old/InvoiceCard.tsx new file mode 100644 index 0000000..9b5fae6 --- /dev/null +++ b/eval/fixtures/g3-version-skew/old/InvoiceCard.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +// Pre-rename version (the "production" graph an agent resolves a ticket against). +// On main this definition is renamed AND moved — see ../new/BillingCard.tsx. +// The body is identical across versions, which is what lets diffRenames pair +// them by signature even though the name and file both change. +export function InvoiceCard() { + const [rows, setRows] = useState([]); + + useEffect(() => { + fetch("/api/invoices") + .then((res) => res.json()) + .then(setRows); + }, []); + + return ( +
+

Invoice summary

+

{rows.length} line items

+
+ ); +} diff --git a/packages/agent-sdk/src/bundle.test.ts b/packages/agent-sdk/src/bundle.test.ts index b1f6591..7813b99 100644 --- a/packages/agent-sdk/src/bundle.test.ts +++ b/packages/agent-sdk/src/bundle.test.ts @@ -106,6 +106,66 @@ describe("buildBundle (TRACKER 5.2, F1)", () => { }); }); +describe("buildBundle version skew (6.4, G3/A11)", () => { + // A card that renders "Invoice summary" — identical body across versions, so + // only the name/file distinguish the old graph from the current one. + function cardGraph(name: string, file: string): LineageGraph { + const component: ComponentNode = { + id: nodeId("component", file, name), + kind: "component", + name, + loc: loc(file), + exportName: name, + props: [], + renderedText: [{ text: "Invoice summary", source: "jsx" }], + rendersComponents: [], + structure: { + table: 0, + columns: 0, + form: 0, + input: 0, + button: 0, + link: 0, + image: 0, + heading: 1, + list: 0, + repeated: 0, + }, + }; + return { + version: 2, + root: "/app", + generatedAt: "2026-01-01T00:00:00.000Z", + generator: "test", + nodes: [component], + edges: [], + }; + } + + const resolved = cardGraph("InvoiceCard", "InvoiceCard.tsx"); + const current = cardGraph("BillingCard", "BillingCard.tsx"); + const ticket = { text: "the Invoice summary card is wrong" }; + + it("warns with the new name+file when the matched definition was renamed/moved", () => { + const bundle = buildBundle(resolved, ticket, { currentGraph: current, budgetTokens: 8000 }); + expect(bundle.match[0]?.component).toBe("InvoiceCard"); + const skew = bundle.warnings.find((w) => w.startsWith("version skew")); + expect(skew).toBeDefined(); + expect(skew).toContain("`BillingCard`"); + expect(skew).toContain("BillingCard.tsx"); + }); + + it("emits no version-skew warning when the current graph is unchanged", () => { + const bundle = buildBundle(resolved, ticket, { currentGraph: resolved, budgetTokens: 8000 }); + expect(bundle.warnings.some((w) => w.startsWith("version skew"))).toBe(false); + }); + + it("emits no version-skew warning when no current graph is provided", () => { + const bundle = buildBundle(resolved, ticket, { budgetTokens: 8000 }); + expect(bundle.warnings.some((w) => w.startsWith("version skew"))).toBe(false); + }); +}); + describe("coverage-unmapped downgrade (TRACKER 6F.6, F3)", () => { // A graph where test files EXIST but almost none map to a component — the // field-found signature (tests present, ~0 covered-by edges). The matched diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts index 7293b16..cc2b828 100644 --- a/packages/agent-sdk/src/bundle.ts +++ b/packages/agent-sdk/src/bundle.ts @@ -12,6 +12,8 @@ import { blastRadius, type DataSourceNode, + diffRenames, + findRename, type ImpactNode, type JourneyPath, journeys, @@ -86,6 +88,13 @@ export interface BundleOptions { journeyDepth?: number; /** Max recent commits to include in the history section. Default 5. */ historyLimit?: number; + /** + * The current-code graph (e.g. main) to diff the resolved match against for + * version skew (6.4, G3/A11). When the matched definition was renamed or + * moved in this graph, the bundle warns with the new name/path so the agent + * edits the right file. Omit when resolving against up-to-date code. + */ + currentGraph?: LineageGraph; } /** Trim sections in this reverse-priority order until the bundle fits its budget. */ @@ -146,6 +155,24 @@ export function buildBundle( const top = match.candidates[0]?.value; if (top !== undefined) { + // Version skew (6.4, G3/A11): the match came from the resolved graph, which + // may lag the current code. If the top definition was renamed/moved on the + // current graph, surface the new identity so the agent edits the live file. + if (options.currentGraph !== undefined) { + const renamed = findRename( + diffRenames(graph, options.currentGraph), + top.component.name, + top.component.loc.file, + ); + if (renamed !== undefined) { + const moved = renamed.to.file !== renamed.from.file; + bundle.warnings.push( + `version skew — matched \`${renamed.from.name}\` (${renamed.from.file}) in the resolved graph; ` + + `renamed \`${renamed.to.name}\`${moved ? ` (${renamed.to.file})` : ""} in the current graph`, + ); + } + } + const definitionLineage = traceLineage(graph, top.component.id).candidates[0]?.value; if (definitionLineage !== undefined) { bundle.lineage.push({ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7bf217a..2b6fe53 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,9 +13,11 @@ import { type LineageGraph, loadCorrections, loadGraph as loadGraphFile, + loadGraphFromStore, matchComponents, recordCorrection, saveGraph, + saveGraphToStore, traceLineage, } from "@coderadar/core"; import { buildBundle, resolveContext } from "@coderadar/agent-sdk"; @@ -40,13 +42,21 @@ program .argument("", "directory to scan") .option("-o, --out ", "output file", "ui-lineage.graph.json") .option("--openapi ", "OpenAPI 3 JSON spec (relative to ) for response-schema linking") - .action((dir: string, opts: { out: string; openapi?: string }) => { + .option( + "--store", + "also save into the SHA-keyed store (.coderadar/graphs/.json + latest) for version-skew diffs (6.4)", + ) + .action((dir: string, opts: { out: string; openapi?: string; store?: boolean }) => { const meta = collectGraphMeta(path.resolve(dir)); const graph = { ...resolveHookEdges(scanReact({ root: dir, ...(opts.openapi ? { openapi: opts.openapi } : {}) })), meta, }; saveGraph(graph, opts.out); + if (opts.store === true) { + const stored = saveGraphToStore(graph, path.resolve(dir)); + console.log(` stored: ${stored}`); + } const counts = new Map(); for (const node of graph.nodes) { counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1); @@ -224,16 +234,38 @@ program .option("-g, --graph ", "graph file", "ui-lineage.graph.json") .option("-s, --screenshot", "the ticket has a screenshot attached") .option("-b, --budget ", "token budget", "4000") - .action((text: string, opts: { graph: string; screenshot?: boolean; budget: string }) => { - const graph = loadGraph(opts.graph); - const budgetTokens = Number.parseInt(opts.budget, 10); - const bundle = buildBundle( - graph, - { text, ...(opts.screenshot ? { screenshots: 1 } : {}) }, - { budgetTokens: Number.isNaN(budgetTokens) ? 4000 : budgetTokens }, - ); - console.log(JSON.stringify(bundle, null, 2)); - }); + .option( + "--against ", + "diff the match against a stored graph version (a commit SHA or 'latest') to warn on renamed/moved definitions (6.4)", + ) + .action( + ( + text: string, + opts: { graph: string; screenshot?: boolean; budget: string; against?: string }, + ) => { + const graph = loadGraph(opts.graph); + const budgetTokens = Number.parseInt(opts.budget, 10); + let currentGraph: LineageGraph | undefined; + if (opts.against !== undefined) { + try { + currentGraph = loadGraphFromStore(graph.root, opts.against); + } catch (err) { + console.error(`--against: ${(err as Error).message}`); + process.exitCode = 1; + return; + } + } + const bundle = buildBundle( + graph, + { text, ...(opts.screenshot ? { screenshots: 1 } : {}) }, + { + budgetTokens: Number.isNaN(budgetTokens) ? 4000 : budgetTokens, + ...(currentGraph !== undefined ? { currentGraph } : {}), + }, + ); + console.log(JSON.stringify(bundle, null, 2)); + }, + ); program .command("impact") diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b8f269a..fe8105c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,4 +3,5 @@ export * from "./result.js"; export * from "./query.js"; export * from "./corrections.js"; export * from "./storage.js"; +export * from "./renames.js"; export * from "./text.js"; diff --git a/packages/core/src/renames.test.ts b/packages/core/src/renames.test.ts new file mode 100644 index 0000000..98b7507 --- /dev/null +++ b/packages/core/src/renames.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { diffRenames, findRename } from "./renames.js"; +import { type ComponentNode, type LineageGraph, nodeId } from "./types.js"; + +const zeroStructure = { + table: 0, + columns: 0, + form: 0, + input: 0, + button: 0, + link: 0, + image: 0, + heading: 0, + list: 0, + repeated: 0, +}; + +function component(name: string, file: string, texts: string[]): ComponentNode { + return { + id: nodeId("component", file, name), + kind: "component", + name, + loc: { file, line: 1, endLine: 1 }, + exportName: name, + props: [], + renderedText: texts.map((text) => ({ text, source: "jsx" as const })), + rendersComponents: [], + structure: { ...zeroStructure, heading: texts.length }, + }; +} + +function graph(components: ComponentNode[]): LineageGraph { + return { + version: 2, + root: "/app", + generatedAt: "2026-01-01T00:00:00.000Z", + generator: "test", + nodes: components, + edges: [], + }; +} + +describe("diffRenames (6.4, G3/A11)", () => { + it("pairs a renamed-in-place definition by body signature", () => { + const from = graph([component("InvoiceCard", "InvoiceCard.tsx", ["Invoice summary"])]); + const to = graph([component("BillingCard", "InvoiceCard.tsx", ["Invoice summary"])]); + expect(diffRenames(from, to)).toStrictEqual([ + { + from: { name: "InvoiceCard", file: "InvoiceCard.tsx" }, + to: { name: "BillingCard", file: "InvoiceCard.tsx" }, + }, + ]); + }); + + it("pairs a moved-file definition (same name, different path)", () => { + const from = graph([component("Card", "old/Card.tsx", ["Balance due"])]); + const to = graph([component("Card", "new/Card.tsx", ["Balance due"])]); + expect(diffRenames(from, to)).toStrictEqual([ + { from: { name: "Card", file: "old/Card.tsx" }, to: { name: "Card", file: "new/Card.tsx" } }, + ]); + }); + + it("reports nothing when the definition is unchanged", () => { + const g = graph([component("Card", "Card.tsx", ["Balance due"])]); + expect(diffRenames(g, g)).toStrictEqual([]); + }); + + it("does not pair generic empty-bodied components (no discriminating signature)", () => { + const from = graph([component("Empty", "Empty.tsx", [])]); + const to = graph([component("Renamed", "Empty.tsx", [])]); + expect(diffRenames(from, to)).toStrictEqual([]); + }); + + it("skips ambiguous pairings where two definitions share a body signature", () => { + const from = graph([ + component("A", "A.tsx", ["Shared body"]), + component("B", "B.tsx", ["Shared body"]), + ]); + const to = graph([ + component("C", "C.tsx", ["Shared body"]), + component("D", "D.tsx", ["Shared body"]), + ]); + // Two gone + two arrived with the same signature — not a confident 1:1. + expect(diffRenames(from, to)).toStrictEqual([]); + }); + + it("does not treat a body edit as a rename", () => { + const from = graph([component("Card", "Card.tsx", ["Old text"])]); + const to = graph([component("Panel", "Panel.tsx", ["Totally different text"])]); + expect(diffRenames(from, to)).toStrictEqual([]); + }); + + it("findRename locates a rename by old name+file", () => { + const renames = diffRenames( + graph([component("InvoiceCard", "InvoiceCard.tsx", ["Invoice summary"])]), + graph([component("BillingCard", "BillingCard.tsx", ["Invoice summary"])]), + ); + expect(findRename(renames, "InvoiceCard", "InvoiceCard.tsx")?.to.name).toBe("BillingCard"); + expect(findRename(renames, "Nope", "Nope.tsx")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/renames.ts b/packages/core/src/renames.ts new file mode 100644 index 0000000..ee2d408 --- /dev/null +++ b/packages/core/src/renames.ts @@ -0,0 +1,120 @@ +/** + * Cross-version rename/move detection (TRACKER step 6.4, failure modes G3/A11). + * + * An agent often resolves a ticket against a *stored* graph -- the one that was + * scanned from the commit currently in production -- while the code has moved on + * (main). If `InvoiceCard` was renamed to `BillingCard`, or moved to another + * file, a match in the old graph points at a definition that no longer exists + * under that identity. `diffRenames` pairs those gone definitions with their + * new identity by a body signature that survives a rename or move, so the + * bundle can warn: "matched `InvoiceCard`; renamed `BillingCard` on main". + */ +import { normalizeText } from "./text.js"; +import type { ComponentNode, LineageGraph } from "./types.js"; + +/** One definition whose identity changed between two graph versions. */ +export interface RenamedDefinition { + from: { name: string; file: string }; + to: { name: string; file: string }; +} + +/** A definition's identity within a graph: its name and file. */ +function identity(c: ComponentNode): string { + return `${c.name} ${c.loc.file}`; +} + +/** + * A signature of a component's body that is stable across a rename or file + * move: its structural fingerprint plus its normalized rendered text, props, + * and the components it renders -- none of which change when only the name or + * path does. Returns null for a component with no discriminating body (no text + * and an all-zero structure): such components are too generic to pair + * confidently and would produce false renames. + */ +function bodySignature(c: ComponentNode): string | null { + const text = (c.renderedText ?? []) + .map((e) => normalizeText(e.text)) + .filter((t) => t.length > 0) + .sort(); + const structure = c.structure ?? {}; + const structureTotal = Object.values(structure).reduce((a, b) => a + b, 0); + const renders = c.rendersComponents ?? []; + if (text.length === 0 && structureTotal === 0 && renders.length === 0) return null; + return JSON.stringify({ + structure, + text, + props: [...(c.props ?? [])].sort(), + renders: [...renders].sort(), + }); +} + +/** Components in a graph, keyed by identity. */ +function componentsByIdentity(graph: LineageGraph): Map { + const map = new Map(); + for (const node of graph.nodes) { + if (node.kind === "component") map.set(identity(node), node); + } + return map; +} + +/** + * Definitions present in `fromGraph` but gone (by name+file) in `toGraph`, + * paired with a same-body definition that is new in `toGraph`. Only confident + * 1:1 matches are reported: a signature that is unique among the gone + * definitions and unique among the new definitions. Ambiguous or generic + * bodies are skipped rather than guessed. + */ +export function diffRenames(fromGraph: LineageGraph, toGraph: LineageGraph): RenamedDefinition[] { + const fromById = componentsByIdentity(fromGraph); + const toById = componentsByIdentity(toGraph); + + const groupBySignature = ( + byId: Map, + excludeIn: Map, + ): Map => { + const bySig = new Map(); + for (const [id, c] of byId) { + if (excludeIn.has(id)) continue; // unchanged identity -- not gone / not new + const sig = bodySignature(c); + if (sig === null) continue; + const bucket = bySig.get(sig); + if (bucket === undefined) bySig.set(sig, [c]); + else bucket.push(c); + } + return bySig; + }; + // Signature -> components, for definitions gone-from / new-to the other graph. + const goneBySig = groupBySignature(fromById, toById); + const newBySig = groupBySignature(toById, fromById); + + const renames: RenamedDefinition[] = []; + for (const [sig, gone] of goneBySig) { + const arrived = newBySig.get(sig); + // Confident only when exactly one gone def and exactly one new def share the + // signature -- otherwise the pairing is ambiguous. + if (gone.length !== 1 || arrived === undefined || arrived.length !== 1) continue; + const before = gone[0]; + const after = arrived[0]; + if (before === undefined || after === undefined) continue; + // A pure no-op (same name and file) can't reach here (identity would match), + // so this is always a genuine rename, move, or both. + renames.push({ + from: { name: before.name, file: before.loc.file }, + to: { name: after.name, file: after.loc.file }, + }); + } + // Stable output order (6.3): by the old identity. + renames.sort((a, b) => + `${a.from.name} ${a.from.file}` < `${b.from.name} ${b.from.file}` ? -1 : 1, + ); + return renames; +} + +/** The rename whose `from` matches this name+file, if any (for bundle warnings). */ +export function findRename( + renames: RenamedDefinition[], + name: string, + file: string, +): RenamedDefinition | undefined { + return renames.find((r) => r.from.name === name && r.from.file === file); +} diff --git a/packages/core/src/storage.test.ts b/packages/core/src/storage.test.ts index 8e8f3b2..78da4a9 100644 --- a/packages/core/src/storage.test.ts +++ b/packages/core/src/storage.test.ts @@ -7,7 +7,15 @@ import { describe, expect, it } from "vitest"; // eslint-disable-next-line import/no-relative-packages import { generatorConfig, schemaOutPath } from "../scripts/schema-config.mjs"; -import { collectGraphMeta, loadGraph, saveGraph, SCHEMA_VERSION } from "./storage.js"; +import { + collectGraphMeta, + graphStoreDir, + loadGraph, + loadGraphFromStore, + saveGraph, + saveGraphToStore, + SCHEMA_VERSION, +} from "./storage.js"; import type { LineageGraph } from "./types.js"; const tmp = (name: string) => path.join(fs.mkdtempSync(path.join(os.tmpdir(), "coderadar-")), name); @@ -46,6 +54,44 @@ describe("saveGraph / loadGraph", () => { }); }); +describe("SHA-keyed graph store (6.4)", () => { + const root = () => fs.mkdtempSync(path.join(os.tmpdir(), "coderadar-store-")); + + it("saves under the commit SHA and follows the latest pointer", () => { + const dir = root(); + saveGraphToStore(graph, dir); + expect(fs.existsSync(path.join(graphStoreDir(dir), "abc123.json"))).toBe(true); + expect(fs.readFileSync(path.join(graphStoreDir(dir), "latest"), "utf-8").trim()).toBe("abc123"); + expect(loadGraphFromStore(dir)).toEqual(graph); + expect(loadGraphFromStore(dir, "abc123")).toEqual(graph); + }); + + it("keys a non-git graph as 'working'", () => { + const dir = root(); + const working: LineageGraph = { ...graph, meta: { commitSha: null, dirty: true } }; + saveGraphToStore(working, dir); + expect(fs.existsSync(path.join(graphStoreDir(dir), "working.json"))).toBe(true); + expect(loadGraphFromStore(dir, "working")).toEqual(working); + }); + + it("keeps multiple versions and repoints latest to the newest", () => { + const dir = root(); + const v1: LineageGraph = { ...graph, meta: { commitSha: "sha1", dirty: false } }; + const v2: LineageGraph = { ...graph, meta: { commitSha: "sha2", dirty: false } }; + saveGraphToStore(v1, dir); + saveGraphToStore(v2, dir); + expect(loadGraphFromStore(dir, "sha1").meta?.commitSha).toBe("sha1"); + expect(loadGraphFromStore(dir).meta?.commitSha).toBe("sha2"); + }); + + it("throws with an actionable message for a missing version or empty store", () => { + const dir = root(); + expect(() => loadGraphFromStore(dir)).toThrow(/no graphs in the store/); + saveGraphToStore(graph, dir); + expect(() => loadGraphFromStore(dir, "deadbeef")).toThrow(/no stored graph for version/); + }); +}); + describe("collectGraphMeta", () => { it("returns a SHA inside a git repo", () => { const meta = collectGraphMeta(path.dirname(schemaOutPath)); diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts index 51a86f2..a486636 100644 --- a/packages/core/src/storage.ts +++ b/packages/core/src/storage.ts @@ -9,12 +9,61 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; +import path from "node:path"; import type { GraphMeta, LineageGraph } from "./types.js"; /** The schema version this library reads and writes. */ export const SCHEMA_VERSION = 2; +/** Directory the SHA-keyed graph store lives in, under a scanned root (6.4). */ +export function graphStoreDir(root: string): string { + return path.join(root, ".coderadar", "graphs"); +} + +/** Store key for a graph: its scanned commit SHA, or "working" when not in git. */ +function storeKey(graph: LineageGraph): string { + return graph.meta?.commitSha ?? "working"; +} + +/** + * Persist a graph in the SHA-keyed store (6.4, G3): `.coderadar/graphs/.json` + * plus a `latest` pointer file naming the most recently stored key. Keeping graphs + * by commit lets the query side resolve a ticket against the exact code version + * that was in production while newer scans accumulate. Returns the written path. + */ +export function saveGraphToStore(graph: LineageGraph, root: string): string { + const dir = graphStoreDir(root); + fs.mkdirSync(dir, { recursive: true }); + const key = storeKey(graph); + const file = path.join(dir, `${key}.json`); + saveGraph(graph, file); + fs.writeFileSync(path.join(dir, "latest"), key); + return file; +} + +/** + * Load a graph from the SHA-keyed store. `version` is a stored key (a commit SHA + * or "working"); the default "latest" follows the pointer written by + * `saveGraphToStore`. Throws with an actionable message when the key is absent. + */ +export function loadGraphFromStore(root: string, version = "latest"): LineageGraph { + const dir = graphStoreDir(root); + let key = version; + if (version === "latest") { + const pointer = path.join(dir, "latest"); + if (!fs.existsSync(pointer)) { + throw new Error(`no graphs in the store at ${dir} — run \`ui-lineage scan --store\` first`); + } + key = fs.readFileSync(pointer, "utf-8").trim(); + } + const file = path.join(dir, `${key}.json`); + if (!fs.existsSync(file)) { + throw new Error(`no stored graph for version "${key}" at ${file}`); + } + return loadGraph(file); +} + export function saveGraph(graph: LineageGraph, filePath: string): void { fs.writeFileSync(filePath, JSON.stringify(graph, null, 2)); } diff --git a/packages/parser-react/src/version-skew.test.ts b/packages/parser-react/src/version-skew.test.ts new file mode 100644 index 0000000..f6e15da --- /dev/null +++ b/packages/parser-react/src/version-skew.test.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { diffRenames } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const fixtureRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/g3-version-skew", +); + +const oldGraph = resolveHookEdges(scanReact({ root: path.join(fixtureRoot, "old") })); +const newGraph = resolveHookEdges(scanReact({ root: path.join(fixtureRoot, "new") })); + +describe("version skew on real scans (6.4, G3/A11)", () => { + it("pairs a renamed + moved definition across two scanned graphs", () => { + const renames = diffRenames(oldGraph, newGraph); + expect(renames).toStrictEqual([ + { + from: { name: "InvoiceCard", file: "InvoiceCard.tsx" }, + to: { name: "BillingCard", file: "BillingCard.tsx" }, + }, + ]); + }); + + it("reports nothing when diffing a graph against itself", () => { + expect(diffRenames(oldGraph, oldGraph)).toStrictEqual([]); + expect(diffRenames(newGraph, newGraph)).toStrictEqual([]); + }); +});