From 00f2ea58753e0b8a4b5602ecd9606a59e1117fc3 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 02:05:11 +0530 Subject: [PATCH 01/49] =?UTF-8?q?docs:=20rewrite=20README=20=E2=80=94=20pr?= =?UTF-8?q?oblem=20statement,=20architecture,=20roadmap=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- README.md | 152 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 41f65dc..82c3aac 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,146 @@ -# CodeRadar +# πŸ“‘ CodeRadar -Static analysis toolkit that maps UI components to their data sources (APIs, state, events) β€” enabling AI agents to trace any screenshot back to the code and data behind it. +> **Screenshot in. Full data lineage out.** +> Static analysis that maps every UI component to the APIs, state, and events behind it β€” built for AI agents doing real development work. -**The problem:** a Jira ticket has a screenshot of a broken screen. Which component is that? Where does its data come from? Today an engineer greps for the visible text, follows imports by hand, and reads hook bodies to find the API call. CodeRadar does that walk statically, once, and hands the result to an agent as a queryable graph. +**Status:** pre-release Β· v0.1 proof of concept Β· [build plan](TRACKER.md) + +--- + +## The problem + +A Jira ticket lands with a screenshot: *"the numbers on this table are wrong."* + +Before any fix can start, someone has to answer: + +1. **Which component is that?** β€” grep for visible text, hope it isn't behind an i18n key +2. **Where does its data come from?** β€” follow imports, unwrap hooks, find the API call +3. **What happens if I change it?** β€” who else renders this component? who else calls this API? + +An engineer does this walk by hand, every ticket. An AI dev agent can't do it reliably at all β€” it sees files, not the *lineage* connecting a pixel on screen to the endpoint that produced it. + +## What CodeRadar does + +CodeRadar scans a React codebase **once, statically** β€” no running app required β€” and produces a queryable **lineage graph**: ``` -Screenshot text ──find──▢ Component ──trace──▢ APIs Β· state Β· events + find trace impact +Screenshot ──▢ Component instance ──▢ APIs Β· state Β· events ──▢ Blast radius + text β”‚ + └─ per call site: the same on the Users page + is fed by /api/users; on the Invoices page by /api/invoices ``` +It's designed as a **context-provider node for multi-agent development pipelines**: a ticket goes in, and a budgeted context bundle comes out β€” matched components with evidence, their data sources, the relevant user journeys, blast radius, tests, and git history. Every answer carries confidence and evidence; when the graph isn't sure, it says `ambiguous` and tells you what would disambiguate β€” it never hands your pipeline a confident guess. + ## Quick start ```bash -pnpm install && pnpm build +git clone https://github.com/officialCodeWork/CodeRadar.git +cd CodeRadar && pnpm install && pnpm build +``` -# 1. Scan a React codebase into a lineage graph +**1 β€” Scan a React codebase into a lineage graph** + +```bash node packages/cli/dist/index.js scan ./my-app/src -o graph.json +``` + +**2 β€” Find the component behind a screenshot** (use any text visible in the image) -# 2. Find the component behind a screenshot (use text visible in the image) +```bash node packages/cli/dist/index.js find "Team Members" -g graph.json -# UserList (components/UserList.tsx:4) score=1 +``` +``` +UserList (components/UserList.tsx:4) score=1 + matched: team members +``` + +**3 β€” Trace everything that feeds it** -# 3. Trace everything that feeds it +```bash node packages/cli/dist/index.js trace UserList -g graph.json -# UserList (components/UserList.tsx:4) -# data sources: -# [fetch] GET /api/users (hooks/useUsers.ts:14) -# [fetch] DELETE /api/users/${user.id} (components/UserCard.tsx:5) -# state: -# [useState] users (hooks/useUsers.ts:10) -# events: -# onClick β†’ handleDelete (components/UserCard.tsx:12) -# via: useUsers, UserCard +``` +``` +UserList (components/UserList.tsx:4) + data sources: + [fetch] GET /api/users (hooks/useUsers.ts:14) + [fetch] DELETE /api/users/${user.id} (components/UserCard.tsx:5) + state: + [useState] users (hooks/useUsers.ts:10) + [useState] loading (hooks/useUsers.ts:11) + events: + onClick β†’ handleDelete (components/UserCard.tsx:12) + via: useUsers, UserCard ``` -Try it on the bundled example: `node packages/cli/dist/index.js scan examples/demo-app/src`. +Note the transitivity: `UserList` itself contains no fetch β€” the lineage flows through the `useUsers` hook and the `UserCard` child automatically. + +Try it immediately on the bundled example: `node packages/cli/dist/index.js scan examples/demo-app/src` + +## How it works + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CodeRadar β”‚ +β”‚ β”‚ +β”‚ Parsers (language-specific) Graph (language-agnostic) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ parser-react │──┐ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ (ts-morph) β”‚ β”‚ β”‚ LineageGraph (JSON) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”œβ”€β”€β–Άβ”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ Component ──renders──▢ Componentβ”‚ β”‚ +β”‚ β”‚ parser-python β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ (planned) │─── β”‚ fetches-from ──▢ DataSource β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ reads-state ──▢ State β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ handles ──▢ Event β”‚ β”‚ +β”‚ β”‚ parser-go β”‚β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ (planned) β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–Ό β”‚ +β”‚ Query layer: find Β· trace Β· impact β”‚ +β”‚ (CLI Β· SDK Β· MCP server, planned) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Parsers are language-specific; the graph is not.** Every parser emits the same `LineageGraph` JSON, and any agent β€” Python, Go, TypeScript β€” consumes it without touching a parser. The planned backend parsers make endpoints a *join key*: `fetches-from: /api/users` in your React repo links to `serves: /api/users` in your FastAPI repo, closing the loop from pixel to handler. ## Packages | Package | Purpose | |---------|---------| -| [`@coderadar/core`](packages/core) | The `LineageGraph` schema (plain JSON, language-agnostic) + query helpers (`matchComponentsByText`, `traceLineage`) | -| [`@coderadar/parser-react`](packages/parser-react) | ts-morph–based parser: components, hooks, fetch/axios/react-query/swr calls, useState/useSelector/useContext, JSX event handlers, rendered text | -| [`@coderadar/cli`](packages/cli) | `coderadar scan` / `find` / `trace` | - -## What the graph captures +| [`@coderadar/core`](packages/core) | The `LineageGraph` schema + query primitives (`matchComponentsByText`, `traceLineage`) | +| [`@coderadar/parser-react`](packages/parser-react) | ts-morph static parser β€” components (incl. `memo`/`forwardRef`), hooks, `fetch`/`axios`/react-query/SWR endpoints, `useState`/`useSelector`/`useContext`, JSX event handlers, rendered-text extraction | +| [`@coderadar/cli`](packages/cli) | `coderadar scan` Β· `find` Β· `trace` | -- **ComponentNode** β€” file, export, props, *rendered text* (JSX text + placeholder/label/title/alt/aria-label β€” the screenshot-matching signal), child components -- **DataSourceNode** β€” endpoint as written in source (template placeholders preserved), HTTP method, client kind (fetch / axios / react-query / swr) -- **StateNode** β€” useState / useReducer / useContext / redux useSelector / zustand useStore -- **EventNode** β€” `on*` JSX handlers and the functions they call -- **Edges** β€” `renders`, `uses-hook`, `fetches-from`, `reads-state`, `handles`, `triggers` +### What the graph captures -Lineage is transitive: `trace UserList` follows `UserList β†’ useUsers β†’ fetch("/api/users")` and `UserList β†’ UserCard β†’ DELETE` in one query. +- **Components** β€” file, exports, props, child components, and *rendered text* (JSX text + `placeholder`/`label`/`title`/`alt`/`aria-label`) β€” the screenshot-matching signal +- **Data sources** β€” endpoint as written in source (template placeholders preserved), HTTP method, client kind (fetch / axios / react-query / SWR) +- **State** β€” `useState` / `useReducer` / `useContext` / redux `useSelector` / zustand `useStore` +- **Events** β€” `on*` handlers and the functions they invoke +- **Edges** β€” `renders` Β· `uses-hook` Β· `fetches-from` Β· `reads-state` Β· `handles` Β· `triggers` -## Architecture +## Where this is going -Parsers are language-specific; the graph is not. Any parser emits the same `LineageGraph` JSON, and any agent (Python, Go, TS) consumes it without touching the parsers. +The v0.1 PoC proves the chain on clean code. Real codebases fight back β€” endpoints behind wrapper clients, text behind i18n keys, handlers drilled through four layers of props, one shared component fed different APIs per page. We catalogued **53 ways this can fail** before writing the plan, and every phase of the build is gated by evals that measure exactly those failure modes. -Planned parsers: Python (FastAPI/Django routes) and Go (handlers) so backend endpoints join the same graph as the frontend components that call them β€” closing the loop from pixel to database. +| Document | What's in it | +|----------|--------------| +| **[TRACKER.md](TRACKER.md)** | The build plan: 8 phases, 40 steps, acceptance criteria per step β€” read this first | +| **[docs/failure-modes.md](docs/failure-modes.md)** | The failure catalog (IDs `A1`–`G8`) every step and eval fixture maps to | +| **[docs/testing-strategy.md](docs/testing-strategy.md)** | Eval harness, golden fixtures, metrics (incl. *poison rate* β€” confidently-wrong answers), phase gates | -## Roadmap & planning +Milestones at a glance: -CodeRadar is built as a context-provider node for a multi-agent development pipeline: -Jira ticket in β†’ context bundle (matched components, data lineage, journeys, blast radius) out. +- **M2** β€” per-instance attribution: shared components correctly attributed per call site +- **M3** β€” n-level user journeys, lazily expanded (cycles welcome) +- **M4** β€” screenshot/text β†’ ranked, calibrated, *honest* matches +- **M5** β€” pluggable pipeline node: ticket in β†’ budgeted context bundle out, over MCP +- **M7** β€” full-stack lineage: pixel β†’ backend handler (Python/Go parsers) -- **[TRACKER.md](TRACKER.md)** β€” the phased build plan (8 phases, one step = one PR). Read this first. -- **[docs/failure-modes.md](docs/failure-modes.md)** β€” the catalog of everything that can go wrong (IDs A1–G8); every plan step maps to the failure modes it addresses. -- **[docs/testing-strategy.md](docs/testing-strategy.md)** β€” eval harness, per-failure-mode fixtures, metrics, and the per-phase gates that tell us whether we're on track. +## Contributing -Current state: v0.1 proof of concept (this repo). Next: Phase 0 β€” schema v2 (instance nodes), eval harness, CI. +The project follows a strict step discipline: one TRACKER step = one branch = one PR, and a step is done only when its acceptance criteria and eval gate pass. Found a new way for this to fail? That's a contribution β€” add it to [failure-modes.md](docs/failure-modes.md) with a fixture. ## License From d0268502a7389c4429a0e4a34d89073e335d99ac Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 02:14:19 +0530 Subject: [PATCH 02/49] =?UTF-8?q?feat(core):=20schema=20v2=20=E2=80=94=20i?= =?UTF-8?q?nstance=20nodes,=20evidence,=20confidence,=20query=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0.1 (TRACKER). Addresses C1/D2 schema halves, A5, B5 foundations: - InstanceNode: one per JSX call site (id instance::#), wired owner --renders--> instance --instance-of--> definition; staticProps captured. parentInstanceId lands with the Phase 2.1 instance tree. - Evidence + Confidence types; QueryResult envelope with ok/ambiguous/ declined β€” no query API returns a bare value. matchComponentsByText returns ambiguous + disambiguation question on ties, declined(no-signal) on misses; traceLineage accepts definition or instance ids and walks through instances. - EdgeCondition (flag/role/branch/response) + via on edges; GraphMeta stub; graph version bumped to 2. - vitest wired into core + parser-react: 18 tests (envelope invariants, tie/ decline behavior, instance emission on the demo app, transitive trace). - CLI prints confidence, evidence, instance call sites. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 7 +- packages/cli/src/index.ts | 50 +- packages/core/package.json | 6 +- packages/core/src/index.ts | 1 + packages/core/src/query.test.ts | 177 +++++ packages/core/src/query.ts | 150 +++- packages/core/src/result.ts | 56 ++ packages/core/src/types.ts | 107 ++- packages/core/tsconfig.json | 7 +- packages/parser-react/package.json | 6 +- packages/parser-react/src/scan.test.ts | 66 ++ packages/parser-react/src/scan.ts | 91 ++- packages/parser-react/tsconfig.json | 7 +- pnpm-lock.yaml | 960 +++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + 15 files changed, 1621 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/query.test.ts create mode 100644 packages/core/src/result.ts create mode 100644 packages/parser-react/src/scan.test.ts diff --git a/TRACKER.md b/TRACKER.md index baed8ad..00c237a 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,9 @@ ## Status - **Current phase:** 0 β€” Foundations -- **Next step:** 0.1 β€” Schema v2 (instance nodes, evidence, confidence) -- **Gates passed:** none yet (Gate 0 is the first) +- **Next step:** 0.2 β€” Eval harness + first fixtures +- **Done:** 0.1 +- **Gates passed:** none yet (Gate 0 completes with 0.4) ## What CodeRadar is @@ -42,7 +43,7 @@ Status legend: `[ ]` todo Β· `[~]` in progress Β· `[x]` done Everything else depends on getting the schema and the measurement loop right first. The v0.1 schema is definition-only, which is *wrong* for C1 β€” fix before building on it. -### [ ] 0.1 Schema v2 β€” instances, evidence, confidence +### [x] 0.1 Schema v2 β€” instances, evidence, confidence **Failure modes:** C1 (schema half), D2 (schema half), A5, B5 (edge conditions) **Build:** rework `packages/core/src/types.ts`: - `InstanceNode` β€” `{ id, kind: "instance", definitionId, parentInstanceId | null, loc, staticProps: Record }`. One per JSX call site of a project component. Id: `instance::#`. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 20818f2..9421f87 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import { + type Candidate, + type ComponentMatch, type LineageGraph, matchComponentsByText, traceLineage, @@ -32,7 +34,7 @@ program counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1); } console.log(`Scanned ${path.resolve(dir)}`); - for (const [kind, count] of counts) console.log(` ${kind}: ${count}`); + for (const [kind, count] of [...counts].sort()) console.log(` ${kind}: ${count}`); console.log(` edges: ${graph.edges.length}`); console.log(`Graph written to ${opts.out}`); }); @@ -44,23 +46,23 @@ program .option("-g, --graph ", "graph file", "coderadar.graph.json") .action((terms: string[], opts: { graph: string }) => { const graph = loadGraph(opts.graph); - const matches = matchComponentsByText(graph, terms); - if (matches.length === 0) { - console.log("No components matched."); + const result = matchComponentsByText(graph, terms); + if (result.status === "declined") { + console.log(`No components matched (${result.declineReason}).`); return; } - for (const match of matches.slice(0, 10)) { - console.log( - `${match.component.name} (${match.component.loc.file}:${match.component.loc.line}) score=${match.score}`, - ); - console.log(` matched: ${match.matchedText.join(" | ")}`); + if (result.status === "ambiguous") { + console.log(`Ambiguous β€” ${result.disambiguation}\n`); + } + for (const candidate of result.candidates.slice(0, 10)) { + printMatchCandidate(candidate); } }); program .command("trace") .description("Trace a component to every data source, state, and event that feeds it") - .argument("", "component name or node id") + .argument("", "component name, definition id, or instance id") .option("-g, --graph ", "graph file", "coderadar.graph.json") .action((component: string, opts: { graph: string }) => { const graph = loadGraph(opts.graph); @@ -72,13 +74,15 @@ program process.exitCode = 1; return; } - const lineage = traceLineage(graph, node.id); - if (lineage === null) { - console.error(`Not a component: ${node.id}`); + const result = traceLineage(graph, node.id); + if (result.status === "declined" || result.candidates[0] === undefined) { + console.error(`Cannot trace ${node.id} (${result.declineReason ?? "no result"})`); process.exitCode = 1; return; } - console.log(`${lineage.component.name} (${lineage.component.loc.file}:${lineage.component.loc.line})`); + const lineage = result.candidates[0].value; + const where = lineage.instance ?? lineage.component; + console.log(`${lineage.component.name} (${where.loc.file}:${where.loc.line})`); if (lineage.dataSources.length > 0) { console.log(" data sources:"); for (const ds of lineage.dataSources) { @@ -98,10 +102,26 @@ program } } if (lineage.via.length > 0) { - console.log(` via: ${lineage.via.map((v) => v.name).join(", ")}`); + const labels = lineage.via.map((v) => + v.kind === "instance" ? `${v.name}@${v.loc.file}:${v.loc.line}` : v.name, + ); + console.log(` via: ${labels.join(", ")}`); } + console.log(` confidence: ${result.candidates[0].confidence.level}`); }); +function printMatchCandidate(candidate: Candidate): void { + const match = candidate.value; + console.log( + `${match.component.name} (${match.component.loc.file}:${match.component.loc.line}) ` + + `confidence=${candidate.confidence.level} (${candidate.confidence.score.toFixed(2)})`, + ); + console.log(` matched: ${match.matchedText.join(" | ")}`); + for (const instance of match.instances) { + console.log(` instance: ${instance.loc.file}:${instance.loc.line}`); + } +} + function loadGraph(file: string): LineageGraph { if (!fs.existsSync(file)) { console.error(`Graph file not found: ${file} β€” run \`coderadar scan \` first.`); diff --git a/packages/core/package.json b/packages/core/package.json index e8697e3..e479749 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,9 +17,11 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" }, "devDependencies": { - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.2.7" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fa7ae12..4362be2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ export * from "./types.js"; +export * from "./result.js"; export * from "./query.js"; diff --git a/packages/core/src/query.test.ts b/packages/core/src/query.test.ts new file mode 100644 index 0000000..96cb49c --- /dev/null +++ b/packages/core/src/query.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; + +import { matchComponentsByText, traceLineage } from "./query.js"; +import { confidenceFromScore } from "./result.js"; +import { + type ComponentNode, + type DataSourceNode, + instanceId, + type InstanceNode, + type LineageGraph, + nodeId, +} from "./types.js"; + +const loc = (file: string, line = 1) => ({ file, line, endLine: line }); + +function component(file: string, name: string, renderedText: string[]): ComponentNode { + return { + id: nodeId("component", file, name), + kind: "component", + name, + loc: loc(file), + exportName: name, + props: [], + renderedText, + rendersComponents: [], + }; +} + +function instance(file: string, line: number, definition: ComponentNode): InstanceNode { + return { + id: instanceId(file, line, definition.name), + kind: "instance", + name: definition.name, + loc: loc(file, line), + definitionId: definition.id, + parentInstanceId: null, + staticProps: {}, + }; +} + +function dataSource(file: string, endpoint: string): DataSourceNode { + return { + id: nodeId("data-source", file, `fetch:${endpoint}`), + kind: "data-source", + name: endpoint, + loc: loc(file), + sourceKind: "fetch", + method: "GET", + endpoint, + }; +} + +function graph(nodes: LineageGraph["nodes"], edges: LineageGraph["edges"]): LineageGraph { + return { + version: 2, + root: "/test", + generatedAt: "2026-01-01T00:00:00Z", + generator: "test", + nodes, + edges, + }; +} + +describe("confidenceFromScore", () => { + it("maps thresholds and clamps", () => { + expect(confidenceFromScore(0.9).level).toBe("high"); + expect(confidenceFromScore(0.6).level).toBe("medium"); + expect(confidenceFromScore(0.2).level).toBe("low"); + expect(confidenceFromScore(1.7).score).toBe(1); + expect(confidenceFromScore(-1).score).toBe(0); + }); +}); + +describe("id builders", () => { + it("build canonical ids", () => { + expect(nodeId("component", "a/B.tsx", "B")).toBe("component:a/B.tsx#B"); + expect(instanceId("a/A.tsx", 12, "B")).toBe("instance:a/A.tsx:12#B"); + }); +}); + +describe("matchComponentsByText", () => { + const billing = component("Billing.tsx", "BillingCard", ["Invoice total", "Save"]); + const settings = component("Settings.tsx", "SettingsForm", ["Save", "Notifications"]); + + it("declines with no-signal when nothing matches", () => { + const result = matchComponentsByText(graph([billing, settings], []), ["nonexistent"]); + expect(result.status).toBe("declined"); + expect(result.declineReason).toBe("no-signal"); + expect(result.candidates).toHaveLength(0); + }); + + it("declines when terms are empty or too short", () => { + expect(matchComponentsByText(graph([billing], []), []).status).toBe("declined"); + expect(matchComponentsByText(graph([billing], []), ["a"]).status).toBe("declined"); + }); + + it("returns ok with a clear winner and evidence", () => { + const result = matchComponentsByText(graph([billing, settings], []), [ + "Invoice total", + "Save", + ]); + expect(result.status).toBe("ok"); + const top = result.candidates[0]; + expect(top?.value.component.name).toBe("BillingCard"); + expect(top?.evidence.length).toBe(2); + expect(top?.evidence[0]?.kind).toBe("text-match"); + }); + + it("returns ambiguous with a disambiguation question on ties", () => { + const result = matchComponentsByText(graph([billing, settings], []), ["Save"]); + expect(result.status).toBe("ambiguous"); + expect(result.candidates).toHaveLength(2); + expect(result.disambiguation).toContain("BillingCard"); + expect(result.disambiguation).toContain("SettingsForm"); + }); + + it("includes known instances of matched components", () => { + const inst = instance("Page.tsx", 5, billing); + const result = matchComponentsByText(graph([billing, inst], []), ["Invoice total"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.instances.map((i) => i.id)).toEqual([inst.id]); + }); +}); + +describe("traceLineage", () => { + const parent = component("Parent.tsx", "Parent", ["Team"]); + const child = component("Child.tsx", "Child", []); + const childInstance = instance("Parent.tsx", 8, child); + const api = dataSource("Child.tsx", "/api/users"); + + const g = graph( + [parent, child, childInstance, api], + [ + { from: parent.id, to: childInstance.id, kind: "renders" }, + { from: childInstance.id, to: child.id, kind: "instance-of" }, + { from: child.id, to: api.id, kind: "fetches-from" }, + ], + ); + + it("declines not-found for unknown ids", () => { + expect(traceLineage(g, "component:Nope.tsx#Nope").declineReason).toBe("not-found"); + }); + + it("declines invalid-target for non-component nodes", () => { + expect(traceLineage(g, api.id).declineReason).toBe("invalid-target"); + }); + + it("walks through instances to child data sources", () => { + const result = traceLineage(g, parent.id); + expect(result.status).toBe("ok"); + const lineage = result.candidates[0]?.value; + expect(lineage?.dataSources.map((d) => d.endpoint)).toEqual(["/api/users"]); + expect(lineage?.via.map((v) => v.id)).toContain(childInstance.id); + }); + + it("accepts an instance id as the start and records it", () => { + const result = traceLineage(g, childInstance.id); + expect(result.status).toBe("ok"); + const lineage = result.candidates[0]?.value; + expect(lineage?.instance?.id).toBe(childInstance.id); + expect(lineage?.component.name).toBe("Child"); + expect(lineage?.dataSources.map((d) => d.endpoint)).toEqual(["/api/users"]); + }); + + it("terminates on cyclic graphs", () => { + const cyclic = graph( + [parent, child, childInstance], + [ + { from: parent.id, to: childInstance.id, kind: "renders" }, + { from: childInstance.id, to: child.id, kind: "instance-of" }, + { from: child.id, to: parent.id, kind: "renders" }, + ], + ); + const result = traceLineage(cyclic, parent.id); + expect(result.status).toBe("ok"); + }); +}); diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index d25120e..849b06c 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -1,12 +1,25 @@ /** * Query helpers over a LineageGraph β€” the primitives an agent composes to go * from "text seen in a screenshot" to "component" to "everything that feeds it". + * + * Every function returns a QueryResult envelope: ranked candidates with + * evidence and confidence, or an honest ambiguous/declined. */ +import { + ambiguous, + type Candidate, + confidenceFromScore, + declined, + ok, + type QueryResult, +} from "./result.js"; import type { ComponentNode, DataSourceNode, EventNode, + Evidence, + InstanceNode, LineageEdge, LineageGraph, LineageNode, @@ -15,49 +28,98 @@ import type { export interface ComponentMatch { component: ComponentNode; - /** How many of the query terms matched this component's rendered text. */ - score: number; + /** Call sites of this component, when known. */ + instances: InstanceNode[]; matchedText: string[]; } /** * Rank components by overlap between `terms` (words/phrases read off a - * screenshot) and each component's statically rendered text. + * screenshot or ticket) and each component's statically rendered text. + * + * Returns `ambiguous` when several components tie at the top score, + * `declined("no-signal")` when nothing matches at all. */ -export function matchComponentsByText(graph: LineageGraph, terms: string[]): ComponentMatch[] { +export function matchComponentsByText( + graph: LineageGraph, + terms: string[], +): QueryResult { const needles = terms.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 1); - const matches: ComponentMatch[] = []; + if (needles.length === 0) return declined("no-signal"); + + const instancesByDefinition = groupInstances(graph); + const scored: Array<{ match: ComponentMatch; score: number; evidence: Evidence[] }> = []; for (const node of graph.nodes) { if (node.kind !== "component") continue; const haystack = node.renderedText.map((t) => t.toLowerCase()); const matchedText: string[] = []; + const evidence: Evidence[] = []; for (const needle of needles) { const hit = haystack.find((h) => h.includes(needle) || needle.includes(h)); - if (hit !== undefined) matchedText.push(hit); + if (hit !== undefined) { + matchedText.push(hit); + evidence.push({ + kind: "text-match", + detail: `"${needle}" matched rendered text "${hit}"`, + loc: node.loc, + }); + } } if (matchedText.length > 0) { - matches.push({ component: node, score: matchedText.length, matchedText }); + scored.push({ + match: { + component: node, + instances: instancesByDefinition.get(node.id) ?? [], + matchedText, + }, + score: matchedText.length / needles.length, + evidence, + }); } } - return matches.sort((a, b) => b.score - a.score); + if (scored.length === 0) return declined("no-signal"); + + scored.sort( + (a, b) => b.score - a.score || a.match.component.name.localeCompare(b.match.component.name), + ); + const candidates: Candidate[] = scored.map((s) => ({ + value: s.match, + confidence: confidenceFromScore(s.score), + evidence: s.evidence, + })); + + const top = scored[0]; + const tied = top === undefined ? [] : scored.filter((s) => s.score === top.score); + if (tied.length > 1) { + const names = tied.map((s) => s.match.component.name).join(", "); + return ambiguous( + candidates, + `Multiple components match equally well (${names}). ` + + `Which file or page is the screenshot from, or what other text is visible?`, + ); + } + return ok(candidates); } export interface Lineage { component: ComponentNode; + /** The instance the trace started from, when an instance id was given. */ + instance: InstanceNode | null; dataSources: DataSourceNode[]; state: StateNode[]; events: EventNode[]; - /** Hooks and child components on the path, in discovery order. */ + /** Hooks, instances, and child components on the path, in discovery order. */ via: LineageNode[]; } /** - * Walk outgoing edges from a component (transitively, through hooks and child - * components) and collect every data source, state node, and event that feeds it. + * Walk outgoing edges from a component or instance (transitively, through + * hooks, instances, and child components) and collect every data source, + * state node, and event that feeds it. */ -export function traceLineage(graph: LineageGraph, componentId: string): Lineage | null { +export function traceLineage(graph: LineageGraph, id: string): QueryResult { const byId = new Map(graph.nodes.map((n) => [n.id, n])); const out = new Map(); for (const e of graph.edges) { @@ -66,17 +128,40 @@ export function traceLineage(graph: LineageGraph, componentId: string): Lineage else out.set(e.from, [e]); } - const start = byId.get(componentId); - if (start === undefined || start.kind !== "component") return null; + const start = byId.get(id); + if (start === undefined) return declined("not-found"); - const lineage: Lineage = { component: start, dataSources: [], state: [], events: [], via: [] }; - const seen = new Set([componentId]); - const queue: string[] = [componentId]; + let component: ComponentNode; + let instance: InstanceNode | null = null; + if (start.kind === "component") { + component = start; + } else if (start.kind === "instance") { + const definition = byId.get(start.definitionId); + if (definition === undefined || definition.kind !== "component") return declined("not-found"); + instance = start; + component = definition; + } else { + return declined("invalid-target"); + } + + const lineage: Lineage = { + component, + instance, + dataSources: [], + state: [], + events: [], + via: [], + }; + const startId = instance !== null ? instance.id : component.id; + const seen = new Set([startId, component.id]); + const queue: string[] = [startId, component.id]; + let edgesWalked = 0; while (queue.length > 0) { - const id = queue.shift(); - if (id === undefined) break; - for (const edge of out.get(id) ?? []) { + const currentId = queue.shift(); + if (currentId === undefined) break; + for (const edge of out.get(currentId) ?? []) { + edgesWalked += 1; if (seen.has(edge.to)) continue; seen.add(edge.to); const node = byId.get(edge.to); @@ -94,6 +179,7 @@ export function traceLineage(graph: LineageGraph, componentId: string): Lineage break; case "hook": case "component": + case "instance": lineage.via.push(node); queue.push(node.id); break; @@ -101,5 +187,27 @@ export function traceLineage(graph: LineageGraph, componentId: string): Lineage } } - return lineage; + const evidence: Evidence[] = [ + { + kind: "edge-chain", + detail: + `Walked ${edgesWalked} edges from ${startId}: ` + + `${lineage.dataSources.length} data sources, ${lineage.state.length} state nodes, ` + + `${lineage.events.length} events via ${lineage.via.length} intermediate nodes`, + loc: component.loc, + }, + ]; + // Static edge traversal is reliable; per-instance attribution sharpens in Phase 2.2. + return ok([{ value: lineage, confidence: confidenceFromScore(0.9), evidence }]); +} + +function groupInstances(graph: LineageGraph): Map { + const byDefinition = new Map(); + for (const node of graph.nodes) { + if (node.kind !== "instance") continue; + const list = byDefinition.get(node.definitionId); + if (list) list.push(node); + else byDefinition.set(node.definitionId, [node]); + } + return byDefinition; } diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts new file mode 100644 index 0000000..2312078 --- /dev/null +++ b/packages/core/src/result.ts @@ -0,0 +1,56 @@ +/** + * The query-result envelope. No CodeRadar query API ever returns a bare value: + * every answer is ranked candidates with evidence and confidence, or an honest + * `ambiguous` / `declined` (failure modes D2, D6, G1). + */ + +import type { Confidence, Evidence } from "./types.js"; + +export type QueryStatus = "ok" | "ambiguous" | "declined"; + +export type DeclineReason = + | "no-signal" // nothing in the graph matched the query at all + | "not-found" // the referenced node id/name does not exist + | "invalid-target" // the node exists but the query does not apply to its kind + | "out-of-scope" // the request is outside CodeRadar's domain (e.g. backend-only ticket) + | "not-our-app"; // the input does not appear to describe this codebase + +export interface Candidate { + value: T; + confidence: Confidence; + evidence: Evidence[]; +} + +export interface QueryResult { + status: QueryStatus; + /** Ranked best-first. Empty when declined. */ + candidates: Candidate[]; + /** For ambiguous results: the question that would disambiguate the top candidates. */ + disambiguation?: string; + /** For declined results. */ + declineReason?: DeclineReason; +} + +export function ok(candidates: Candidate[]): QueryResult { + return { status: "ok", candidates }; +} + +export function ambiguous(candidates: Candidate[], disambiguation: string): QueryResult { + return { status: "ambiguous", candidates, disambiguation }; +} + +export function declined(reason: DeclineReason): QueryResult { + return { status: "declined", candidates: [], declineReason: reason }; +} + +/** + * Map a 0–1 score to a confidence level. + * Provisional thresholds β€” replaced by measured calibration in Phase 4.5. + */ +export function confidenceFromScore(score: number): Confidence { + const clamped = Math.max(0, Math.min(1, score)); + return { + score: clamped, + level: clamped >= 0.8 ? "high" : clamped >= 0.5 ? "medium" : "low", + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9278c75..faeb3ce 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,8 +1,14 @@ /** - * The CodeRadar lineage graph. + * The CodeRadar lineage graph β€” schema v2. * * Parsers (React/TS today; Python/Go later) emit this shape. Agents consume it. * The graph is plain JSON so any language can produce or query it. + * + * v2 changes (TRACKER step 0.1): + * - InstanceNode: a component *as rendered at one call site*. Data attribution is + * per instance, never merged into the definition (failure mode C1). + * - Evidence + Confidence: every query answer carries both (D2). + * - EdgeCondition: flag/role/branch conditions on edges (B5, G5). */ /** Where a node lives in the codebase. */ @@ -15,17 +21,28 @@ export interface SourceLocation { endLine: number; } -export type NodeKind = "component" | "hook" | "data-source" | "state" | "event"; +export type NodeKind = + | "component" + | "hook" + | "instance" + | "data-source" + | "state" + | "event"; export interface BaseNode { - /** Stable id, unique within a graph: `${kind}:${file}#${name}` */ + /** Stable id, unique within a graph. See nodeId()/instanceId(). */ id: string; kind: NodeKind; name: string; loc: SourceLocation; + /** + * Degradation markers, e.g. "incomplete", "depth-limited", + * "unresolved-prop-handler", "external-definition". Absent when clean. + */ + flags?: string[]; } -/** A React component β€” the thing a screenshot shows. */ +/** A React component definition β€” the code, not a usage. */ export interface ComponentNode extends BaseNode { kind: "component"; /** Named or default export, if exported. */ @@ -38,10 +55,31 @@ export interface ComponentNode extends BaseNode { * primary signal for matching a screenshot to a component. */ renderedText: string[]; - /** Names of components this component renders in its JSX. */ + /** Names of components this component renders in its JSX (deduplicated). */ rendersComponents: string[]; } +/** + * A component as rendered at one specific call site. + * + * The same definition rendered on the Users page and the Invoices + * page yields two instances β€” and per-instance data attribution (Phase 2.2) + * is what lets each report a different API. + */ +export interface InstanceNode extends BaseNode { + kind: "instance"; + /** Id of the ComponentNode this instantiates. */ + definitionId: string; + /** + * Enclosing instance in the render tree. Null until the cross-file instance + * tree is built (Phase 2.1); the enclosing *definition* is available via the + * incoming `renders` edge meanwhile. + */ + parentInstanceId: string | null; + /** Props with statically-known string values at this call site. */ + staticProps: Record; +} + /** A custom hook β€” often the bridge between a component and its data. */ export interface HookNode extends BaseNode { kind: "hook"; @@ -87,34 +125,83 @@ export interface EventNode extends BaseNode { handler: string | null; } -export type LineageNode = ComponentNode | HookNode | DataSourceNode | StateNode | EventNode; +export type LineageNode = + | ComponentNode + | InstanceNode + | HookNode + | DataSourceNode + | StateNode + | EventNode; export type EdgeKind = - | "renders" // component -> component + | "renders" // component|instance -> instance (definition-level until Phase 2.1) + | "instance-of" // instance -> component definition | "uses-hook" // component -> hook, hook -> hook | "fetches-from" // component | hook -> data-source + | "provides-data" // data-source -> instance (via a prop; Phase 2.2) | "reads-state" // component | hook -> state + | "writes-state" // data-source | event -> state (Phase 2.4) | "handles" // component -> event | "triggers"; // event -> data-source | state (handler causes a fetch / state write) +/** A statically-detected condition guarding an edge (feature flag, role, branch). */ +export interface EdgeCondition { + kind: "flag" | "role" | "branch" | "response"; + /** Source text of the condition, e.g. `isEnabled("new-billing")`. */ + expression: string; +} + export interface LineageEdge { from: string; to: string; kind: EdgeKind; + /** Prop name for provides-data edges; handler name for triggers. */ + via?: string; + condition?: EdgeCondition; +} + +/** How a query answer was derived. Every candidate carries at least one. */ +export interface Evidence { + kind: "text-match" | "structure" | "edge-chain" | "alias" | "correction"; + /** Human/agent-readable derivation, e.g. `"team members" matched renderedText`. */ + detail: string; + loc?: SourceLocation; +} + +export type ConfidenceLevel = "high" | "medium" | "low"; + +export interface Confidence { + level: ConfidenceLevel; + /** 0–1. Level thresholds are provisional until Phase 4.5 calibration. */ + score: number; +} + +/** Scan provenance β€” which code this graph describes. */ +export interface GraphMeta { + /** Commit SHA of the scanned tree; null when not a git repo. */ + commitSha: string | null; + /** True when the working tree had uncommitted changes at scan time. */ + dirty: boolean; } export interface LineageGraph { /** Schema version for forward compatibility. */ - version: 1; + version: 2; /** Absolute path of the scanned root at generation time. */ root: string; generatedAt: string; generator: string; + meta?: GraphMeta; nodes: LineageNode[]; edges: LineageEdge[]; } -/** Build the canonical node id. */ -export function nodeId(kind: NodeKind, file: string, name: string): string { +/** Build the canonical node id for definition-level nodes. */ +export function nodeId(kind: Exclude, file: string, name: string): string { return `${kind}:${file}#${name}`; } + +/** Build the canonical instance id β€” one per JSX call site. */ +export function instanceId(file: string, line: number, name: string): string { + return `instance:${file}:${line}#${name}`; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 5285d28..2eb72d4 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,5 +4,10 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src"] + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts" + ] } diff --git a/packages/parser-react/package.json b/packages/parser-react/package.json index 790c2a4..dfe11b5 100644 --- a/packages/parser-react/package.json +++ b/packages/parser-react/package.json @@ -17,7 +17,8 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" }, "dependencies": { "@coderadar/core": "workspace:*", @@ -25,6 +26,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.2.7" } } diff --git a/packages/parser-react/src/scan.test.ts b/packages/parser-react/src/scan.test.ts new file mode 100644 index 0000000..59faef6 --- /dev/null +++ b/packages/parser-react/src/scan.test.ts @@ -0,0 +1,66 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { matchComponentsByText, traceLineage } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const demoApp = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../examples/demo-app/src", +); + +const graph = resolveHookEdges(scanReact({ root: demoApp })); + +describe("scanReact on the demo app", () => { + it("emits schema v2", () => { + expect(graph.version).toBe(2); + }); + + it("finds both component definitions and the hook", () => { + const components = graph.nodes.filter((n) => n.kind === "component").map((n) => n.name); + expect(components.sort()).toEqual(["UserCard", "UserList"]); + expect(graph.nodes.some((n) => n.kind === "hook" && n.name === "useUsers")).toBe(true); + }); + + it("emits one UserCard instance rendered by UserList", () => { + const instances = graph.nodes.filter((n) => n.kind === "instance"); + expect(instances).toHaveLength(1); + const [inst] = instances; + if (inst === undefined || inst.kind !== "instance") throw new Error("unreachable"); + expect(inst.name).toBe("UserCard"); + expect(inst.definitionId).toBe("component:components/UserCard.tsx#UserCard"); + expect(inst.loc.file).toBe("components/UserList.tsx"); + + const rendersEdge = graph.edges.find((e) => e.kind === "renders" && e.to === inst.id); + expect(rendersEdge?.from).toBe("component:components/UserList.tsx#UserList"); + const instanceOf = graph.edges.find((e) => e.kind === "instance-of" && e.from === inst.id); + expect(instanceOf?.to).toBe(inst.definitionId); + }); + + it("extracts endpoints with methods", () => { + const sources = graph.nodes.filter((n) => n.kind === "data-source"); + const endpoints = sources.map((s) => (s.kind === "data-source" ? `${s.method} ${s.endpoint}` : "")); + expect(endpoints.sort()).toEqual(["DELETE /api/users/${user.id}", "GET /api/users"]); + }); + + it("matches screenshot text to UserList via the envelope", () => { + const result = matchComponentsByText(graph, ["Team Members"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("UserList"); + }); + + it("traces UserList transitively through the hook and the child instance", () => { + const result = traceLineage(graph, "component:components/UserList.tsx#UserList"); + expect(result.status).toBe("ok"); + const lineage = result.candidates[0]?.value; + expect(lineage?.dataSources.map((d) => d.endpoint).sort()).toEqual([ + "/api/users", + "/api/users/${user.id}", + ]); + expect(lineage?.state.map((s) => s.name).sort()).toEqual(["loading", "users"]); + expect(lineage?.events.map((e) => e.event)).toEqual(["onClick"]); + expect(lineage?.via.map((v) => v.name)).toContain("useUsers"); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index d797741..f72ae36 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -2,6 +2,8 @@ import path from "node:path"; import { type DataSourceKind, + instanceId, + type InstanceNode, type LineageEdge, type LineageGraph, type LineageNode, @@ -13,6 +15,8 @@ import { type CallExpression, type FunctionDeclaration, type FunctionExpression, + type JsxOpeningElement, + type JsxSelfClosingElement, Node, Project, type SourceFile, @@ -37,6 +41,17 @@ interface Declaration { loc: SourceLocation; } +/** A JSX call site of a (possibly project-local) component, recorded per file. */ +interface PendingInstance { + /** Tag head, e.g. "UserCard" for or . */ + tagName: string; + loc: SourceLocation; + staticProps: Record; + /** Node id of the enclosing component/hook declaration. */ + ownerId: string; + file: string; +} + const COMPONENT_NAME = /^[A-Z]/; const HOOK_NAME = /^use[A-Z]/; const TEXT_ATTRIBUTES = new Set([ @@ -68,6 +83,7 @@ export function scanReact(options: ScanOptions): LineageGraph { const nodes = new Map(); const edges: LineageEdge[] = []; + const pendingInstances: PendingInstance[] = []; const addEdge = (edge: LineageEdge): void => { if (!edges.some((e) => e.from === edge.from && e.to === edge.to && e.kind === edge.kind)) { edges.push(edge); @@ -95,6 +111,7 @@ export function scanReact(options: ScanOptions): LineageGraph { renderedText: extractRenderedText(decl.fn), rendersComponents: extractRenderedComponents(decl.fn), }); + collectInstanceSites(decl, id, file, pendingInstances); } else { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } @@ -103,10 +120,10 @@ export function scanReact(options: ScanOptions): LineageGraph { } } - resolveCrossFileEdges(nodes, addEdge); + materializeInstances(pendingInstances, nodes, addEdge); return { - version: 1, + version: 2, root, generatedAt: new Date().toISOString(), generator: "@coderadar/parser-react@0.1.0", @@ -400,30 +417,70 @@ function stateVariableName(call: CallExpression): string | null { return null; } +/** Record every JSX call site of a capitalized component within a declaration body. */ +function collectInstanceSites( + decl: Declaration, + ownerId: string, + file: string, + pendingInstances: PendingInstance[], +): void { + const record = (el: JsxOpeningElement | JsxSelfClosingElement): void => { + const head = el.getTagNameNode().getText().split(".")[0]; + if (head === undefined || !COMPONENT_NAME.test(head)) return; + const staticProps: Record = {}; + for (const attr of el.getAttributes()) { + if (!Node.isJsxAttribute(attr)) continue; + const init = attr.getInitializer(); + if (init !== undefined && Node.isStringLiteral(init)) { + staticProps[attr.getNameNode().getText()] = init.getLiteralValue(); + } + } + pendingInstances.push({ tagName: head, loc: locOf(el, file), staticProps, ownerId, file }); + }; + for (const el of decl.fn.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el); + for (const el of decl.fn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el); +} + /** - * Second pass: resolve `unresolved-hook:useX` edge targets and `rendersComponents` - * names against the declared node set (by name, across files). + * Second pass: turn call sites whose tag resolves to a project component + * definition (matched by name, across files) into InstanceNodes, wired + * owner --renders--> instance --instance-of--> definition. + * + * parentInstanceId stays null until the cross-file instance tree lands in + * Phase 2.1; the enclosing definition is reachable via the renders edge. */ -function resolveCrossFileEdges( +function materializeInstances( + pendingInstances: PendingInstance[], nodes: Map, addEdge: (edge: LineageEdge) => void, ): void { - const byName = new Map(); + const definitionsByName = new Map(); for (const node of nodes.values()) { - const list = byName.get(node.name); - if (list) list.push(node); - else byName.set(node.name, [node]); + if (node.kind === "component") definitionsByName.set(node.name, node.id); } - for (const node of nodes.values()) { - if (node.kind === "component") { - for (const childName of node.rendersComponents) { - const target = byName.get(childName)?.find((n) => n.kind === "component"); - if (target !== undefined && target.id !== node.id) { - addEdge({ from: node.id, to: target.id, kind: "renders" }); - } - } + for (const pending of pendingInstances) { + const definitionId = definitionsByName.get(pending.tagName); + if (definitionId === undefined || definitionId === pending.ownerId) continue; + + let id = instanceId(pending.file, pending.loc.line, pending.tagName); + let suffix = 1; + while (nodes.has(id)) { + suffix += 1; + id = `${instanceId(pending.file, pending.loc.line, pending.tagName)}~${suffix}`; } + const instance: InstanceNode = { + id, + kind: "instance", + name: pending.tagName, + loc: pending.loc, + definitionId, + parentInstanceId: null, + staticProps: pending.staticProps, + }; + nodes.set(id, instance); + addEdge({ from: pending.ownerId, to: id, kind: "renders" }); + addEdge({ from: id, to: definitionId, kind: "instance-of" }); } } diff --git a/packages/parser-react/tsconfig.json b/packages/parser-react/tsconfig.json index 5285d28..2eb72d4 100644 --- a/packages/parser-react/tsconfig.json +++ b/packages/parser-react/tsconfig.json @@ -4,5 +4,10 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src"] + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts" + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40775b9..0eac416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@22.20.1) packages/parser-react: dependencies: @@ -48,21 +51,375 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@22.20.1) packages: + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + '@ts-morph/common@0.25.0': resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -70,6 +427,34 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -79,21 +464,96 @@ packages: picomatch: optional: true + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + ts-morph@24.0.0: resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==} @@ -105,45 +565,466 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + '@ts-morph/common@0.25.0': dependencies: minimatch: 9.0.9 path-browserify: 1.0.1 tinyglobby: 0.2.17 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + code-block-writer@13.0.3: {} commander@13.1.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.2 + ms@2.1.3: {} + + nanoid@3.3.15: {} + path-browserify@1.0.1: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss@8.5.17: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + ts-morph@24.0.0: dependencies: '@ts-morph/common': 0.25.0 @@ -152,3 +1033,82 @@ snapshots: typescript@5.9.3: {} undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.17 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@22.20.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1) + vite-node: 3.2.4(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..7e24990 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - "packages/*" +allowBuilds: + esbuild: true From 39814b1850edad8e1c181648bd0f84ff44c576f2 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 02:23:38 +0530 Subject: [PATCH 03/49] feat(eval): eval harness + first failure-mode fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0.2 (TRACKER). The measurement loop every later phase is gated by: - @coderadar/eval: fixture discovery, golden.json diffing, scorecard.json, history.jsonl (--record), thresholds gate with non-zero exit. - Per-check expectedFail (xfail semantics): failing marked checks report xfail and don't gate; passing marked checks report unexpected-pass and DO gate, so capability arrival is an explicit reviewed event. Forbidden (poison) checks never xfail. - Fixtures: c1-shared-datatable (headline case β€” per-instance attributions xfail'd until step 2.2, poison assertions active now), a4-generic-text (ambiguity honesty on 'Save' collisions), demo-app (baseline, app shared with examples/). - Current scorecard: 25 pass / 0 fail / 2 xfail / 0 unexpected-pass; lineage precision 1.000, recall 0.714 (xfail gap), match accuracy 1.000. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + TRACKER.md | 6 +- docs/testing-strategy.md | 29 ++- .../a4-generic-text/app/BillingForm.tsx | 9 + .../a4-generic-text/app/ProfileForm.tsx | 9 + .../a4-generic-text/app/SettingsForm.tsx | 12 ++ eval/fixtures/a4-generic-text/golden.json | 18 ++ .../app/components/DataTable.tsx | 29 +++ .../app/pages/InvoicesPage.tsx | 26 +++ .../app/pages/UsersPage.tsx | 26 +++ eval/fixtures/c1-shared-datatable/golden.json | 57 ++++++ eval/fixtures/demo-app/golden.json | 25 +++ eval/history.jsonl | 1 + eval/package.json | 20 +++ eval/src/checks.ts | 130 ++++++++++++++ eval/src/golden.ts | 97 ++++++++++ eval/src/run.ts | 165 ++++++++++++++++++ eval/thresholds.json | 5 + eval/tsconfig.json | 8 + package.json | 3 +- pnpm-lock.yaml | 16 ++ pnpm-workspace.yaml | 1 + 22 files changed, 683 insertions(+), 12 deletions(-) create mode 100644 eval/fixtures/a4-generic-text/app/BillingForm.tsx create mode 100644 eval/fixtures/a4-generic-text/app/ProfileForm.tsx create mode 100644 eval/fixtures/a4-generic-text/app/SettingsForm.tsx create mode 100644 eval/fixtures/a4-generic-text/golden.json create mode 100644 eval/fixtures/c1-shared-datatable/app/components/DataTable.tsx create mode 100644 eval/fixtures/c1-shared-datatable/app/pages/InvoicesPage.tsx create mode 100644 eval/fixtures/c1-shared-datatable/app/pages/UsersPage.tsx create mode 100644 eval/fixtures/c1-shared-datatable/golden.json create mode 100644 eval/fixtures/demo-app/golden.json create mode 100644 eval/history.jsonl create mode 100644 eval/package.json create mode 100644 eval/src/checks.ts create mode 100644 eval/src/golden.ts create mode 100644 eval/src/run.ts create mode 100644 eval/thresholds.json create mode 100644 eval/tsconfig.json diff --git a/.gitignore b/.gitignore index 872d5f6..4c08c23 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,6 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Eval outputs (scorecard is regenerated every run; history is recorded deliberately) +eval/scorecard.json diff --git a/TRACKER.md b/TRACKER.md index 00c237a..aac2ecb 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 0 β€” Foundations -- **Next step:** 0.2 β€” Eval harness + first fixtures -- **Done:** 0.1 +- **Next step:** 0.3 β€” Graph storage & versioning +- **Done:** 0.1, 0.2 - **Gates passed:** none yet (Gate 0 completes with 0.4) ## What CodeRadar is @@ -58,7 +58,7 @@ The v0.1 schema is definition-only, which is *wrong* for C1 β€” fix before build - Demo-app scan shows `UserCard` with 1 definition + 1 instance (parent `UserList`). - `matchComponentsByText` / `traceLineage` return `QueryResult` envelopes. -### [ ] 0.2 Eval harness + first fixtures +### [x] 0.2 Eval harness + first fixtures **Failure modes:** infrastructure for all; first fixtures C1, A4 **Build:** `eval/run.ts` (a workspace package `@coderadar/eval`): - Discovers `eval/fixtures/*/`, scans each `app/` dir, diffs the graph + query results against `golden.json` (format in testing-strategy.md, including `forbidden` entries). diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 1d6000c..e9e95aa 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -48,27 +48,39 @@ eval/ └── run.ts # runner: scan fixtures, diff vs golden, emit scorecard ``` -`golden.json` format (per fixture): +`golden.json` format (per fixture; full contract in `eval/src/golden.ts`): ```json { "failureMode": "C1", + "app": "./app", "expect": { "components": [{ "name": "DataTable", "instances": 2 }], "attributions": [ - { "instanceAt": "pages/Users.tsx", "endpoint": "/api/users" }, - { "instanceAt": "pages/Invoices.tsx", "endpoint": "/api/invoices" } + { "component": "DataTable", "instanceAt": "pages/UsersPage.tsx", + "endpoints": ["/api/users"], + "expectedFail": "phase-2: per-instance attribution requires prop-flow (step 2.2)" } ], "forbidden": [ - { "note": "definition-level attribution would claim DataTable calls both APIs", - "attribution": { "definition": "DataTable", "endpoint": "*" } } + { "component": "DataTable", "instanceAt": "pages/UsersPage.tsx", + "endpoint": "/api/invoices", + "note": "poison: invoices API attributed to the users-page table" } + ], + "queries": [ + { "terms": ["Save"], "status": "ambiguous" }, + { "terms": ["All Users"], "status": "ok", "top": "UsersPage" } ] } } ``` -`forbidden` entries catch the *specific wrong answer* each failure mode produces β€” -passing isn't just finding the right edges, it's not emitting the poisonous ones. +- `forbidden` entries catch the *specific wrong answer* each failure mode produces β€” + passing isn't just finding the right edges, it's not emitting the poisonous ones. + Forbidden checks never carry `expectedFail`: poison gates in every phase. +- `expectedFail` (per check) gives xfail semantics: a failing check reports `xfail` + and doesn't gate; a *passing* check still carrying the marker reports + `unexpected-pass` and **does** gate β€” stale markers are removed the moment the + capability lands, so capability arrival is always an explicit, reviewed event. ## Metrics @@ -103,7 +115,8 @@ ratchet upward as reality informs them): ## Monitoring direction over time -- `eval/run.ts` appends every run's scorecard to `eval/history.jsonl` (committed weekly). +- `pnpm eval -- --record` appends the run's summary to `eval/history.jsonl` + (recorded deliberately β€” e.g. at each step completion β€” and committed). A shrinking metric between phases is a **regression investigation**, not noise. - From Phase 4, the corrections store (G4) is periodically folded into `eval/tickets/` β€” real mismatches from the pipeline become permanent eval cases. This is the flywheel: diff --git a/eval/fixtures/a4-generic-text/app/BillingForm.tsx b/eval/fixtures/a4-generic-text/app/BillingForm.tsx new file mode 100644 index 0000000..a9cfc3b --- /dev/null +++ b/eval/fixtures/a4-generic-text/app/BillingForm.tsx @@ -0,0 +1,9 @@ +export function BillingForm() { + return ( +
+

Card number

+ + +
+ ); +} diff --git a/eval/fixtures/a4-generic-text/app/ProfileForm.tsx b/eval/fixtures/a4-generic-text/app/ProfileForm.tsx new file mode 100644 index 0000000..3de0602 --- /dev/null +++ b/eval/fixtures/a4-generic-text/app/ProfileForm.tsx @@ -0,0 +1,9 @@ +export function ProfileForm() { + return ( +
+

Display name

+ + +
+ ); +} diff --git a/eval/fixtures/a4-generic-text/app/SettingsForm.tsx b/eval/fixtures/a4-generic-text/app/SettingsForm.tsx new file mode 100644 index 0000000..c0d812a --- /dev/null +++ b/eval/fixtures/a4-generic-text/app/SettingsForm.tsx @@ -0,0 +1,12 @@ +export function SettingsForm() { + return ( +
+

Notification preferences

+ + +
+ ); +} diff --git a/eval/fixtures/a4-generic-text/golden.json b/eval/fixtures/a4-generic-text/golden.json new file mode 100644 index 0000000..fdadced --- /dev/null +++ b/eval/fixtures/a4-generic-text/golden.json @@ -0,0 +1,18 @@ +{ + "failureMode": "A4", + "note": "Generic text collisions: 'Save' appears in three components. A lone generic term must yield an honest ambiguous (with a disambiguation question), never a coin-flip ok. Adding one distinctive term must resolve it.", + "expect": { + "components": [ + { "name": "SettingsForm", "instances": 0 }, + { "name": "ProfileForm", "instances": 0 }, + { "name": "BillingForm", "instances": 0 } + ], + "queries": [ + { "terms": ["Save"], "status": "ambiguous" }, + { "terms": ["Save", "Card number"], "status": "ok", "top": "BillingForm" }, + { "terms": ["Save", "Notification preferences"], "status": "ok", "top": "SettingsForm" }, + { "terms": ["Save", "Display name"], "status": "ok", "top": "ProfileForm" }, + { "terms": ["Purchase history"], "status": "declined" } + ] + } +} diff --git a/eval/fixtures/c1-shared-datatable/app/components/DataTable.tsx b/eval/fixtures/c1-shared-datatable/app/components/DataTable.tsx new file mode 100644 index 0000000..7518933 --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/components/DataTable.tsx @@ -0,0 +1,29 @@ +export interface Column { + key: string; + label: string; +} + +export function DataTable({ rows, columns }: { rows: Record[]; columns: Column[] }) { + if (rows.length === 0) return

No records found

; + + return ( + + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{col.label}
{row[col.key]}
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/app/pages/InvoicesPage.tsx b/eval/fixtures/c1-shared-datatable/app/pages/InvoicesPage.tsx new file mode 100644 index 0000000..f05bd8d --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/pages/InvoicesPage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import { DataTable } from "../components/DataTable"; + +export function InvoicesPage() { + const [rows, setRows] = useState[]>([]); + + useEffect(() => { + fetch("/api/invoices") + .then((res) => res.json()) + .then(setRows); + }, []); + + return ( +
+

All Invoices

+ +
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/app/pages/UsersPage.tsx b/eval/fixtures/c1-shared-datatable/app/pages/UsersPage.tsx new file mode 100644 index 0000000..d607e57 --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/pages/UsersPage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import { DataTable } from "../components/DataTable"; + +export function UsersPage() { + const [rows, setRows] = useState[]>([]); + + useEffect(() => { + fetch("/api/users") + .then((res) => res.json()) + .then(setRows); + }, []); + + return ( +
+

All Users

+ +
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/golden.json b/eval/fixtures/c1-shared-datatable/golden.json new file mode 100644 index 0000000..04424b9 --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/golden.json @@ -0,0 +1,57 @@ +{ + "failureMode": "C1", + "note": "The headline case: one shared DataTable, two pages, two different APIs. Attribution must be per instance. The forbidden entries catch the poison answer a definition-level trace would give (both endpoints attributed to every usage).", + "expect": { + "components": [ + { "name": "DataTable", "instances": 2 }, + { "name": "UsersPage", "instances": 0 }, + { "name": "InvoicesPage", "instances": 0 } + ], + "attributions": [ + { + "component": "DataTable", + "instanceAt": "pages/UsersPage.tsx", + "endpoints": ["/api/users"], + "expectedFail": "phase-2: per-instance attribution requires prop-flow (step 2.2)" + }, + { + "component": "DataTable", + "instanceAt": "pages/InvoicesPage.tsx", + "endpoints": ["/api/invoices"], + "expectedFail": "phase-2: per-instance attribution requires prop-flow (step 2.2)" + }, + { + "component": "UsersPage", + "endpoints": ["/api/users"] + }, + { + "component": "InvoicesPage", + "endpoints": ["/api/invoices"] + } + ], + "forbidden": [ + { + "component": "DataTable", + "instanceAt": "pages/UsersPage.tsx", + "endpoint": "/api/invoices", + "note": "poison: invoices API attributed to the users-page table" + }, + { + "component": "DataTable", + "instanceAt": "pages/InvoicesPage.tsx", + "endpoint": "/api/users", + "note": "poison: users API attributed to the invoices-page table" + }, + { + "component": "UsersPage", + "endpoint": "/api/invoices", + "note": "poison: cross-page endpoint bleed at definition level" + } + ], + "queries": [ + { "terms": ["All Users"], "status": "ok", "top": "UsersPage" }, + { "terms": ["All Invoices"], "status": "ok", "top": "InvoicesPage" }, + { "terms": ["No records found"], "status": "ok", "top": "DataTable" } + ] + } +} diff --git a/eval/fixtures/demo-app/golden.json b/eval/fixtures/demo-app/golden.json new file mode 100644 index 0000000..c027f35 --- /dev/null +++ b/eval/fixtures/demo-app/golden.json @@ -0,0 +1,25 @@ +{ + "failureMode": "baseline", + "note": "The bundled example app, kept green from day one. App source lives in examples/demo-app so the README quick-start and this fixture share one copy.", + "app": "../../../examples/demo-app/src", + "expect": { + "components": [ + { "name": "UserList", "instances": 0 }, + { "name": "UserCard", "instances": 1 } + ], + "attributions": [ + { + "component": "UserList", + "endpoints": ["/api/users", "/api/users/${user.id}"] + }, + { + "component": "UserCard", + "endpoints": ["/api/users/${user.id}"] + } + ], + "queries": [ + { "terms": ["Team Members"], "status": "ok", "top": "UserList" }, + { "terms": ["Remove member"], "status": "ok", "top": "UserCard" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl new file mode 100644 index 0000000..6dc53e8 --- /dev/null +++ b/eval/history.jsonl @@ -0,0 +1 @@ +{"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1} diff --git a/eval/package.json b/eval/package.json new file mode 100644 index 0000000..12aecaf --- /dev/null +++ b/eval/package.json @@ -0,0 +1,20 @@ +{ + "name": "@coderadar/eval", + "version": "0.1.0", + "private": true, + "description": "CodeRadar eval harness β€” scans failure-mode fixtures, diffs against golden outputs, emits the scorecard that gates every phase.", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@coderadar/core": "workspace:*", + "@coderadar/parser-react": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.7.0" + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts new file mode 100644 index 0000000..f23e443 --- /dev/null +++ b/eval/src/checks.ts @@ -0,0 +1,130 @@ +/** Run one fixture's golden checks against its scanned graph. */ + +import { + type LineageGraph, + matchComponentsByText, + traceLineage, +} from "@coderadar/core"; + +import type { CheckResult, FixtureResult, Golden } from "./golden.js"; + +export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): FixtureResult { + const checks: CheckResult[] = []; + const attribution = { truePositives: 0, falsePositives: 0, falseNegatives: 0 }; + + const finalize = ( + kind: CheckResult["kind"], + id: string, + passed: boolean, + marker: string | undefined, + detail?: string, + ): void => { + let status: CheckResult["status"]; + if (marker !== undefined) { + status = passed ? "unexpected-pass" : "xfail"; + detail = passed + ? `passed but marked expectedFail (${marker}) β€” remove the stale marker` + : marker; + } else { + status = passed ? "pass" : "fail"; + } + checks.push({ id, kind, status, detail }); + }; + + for (const expected of golden.expect.components ?? []) { + const definition = graph.nodes.find( + (n) => n.kind === "component" && n.name === expected.name, + ); + const instances = graph.nodes.filter( + (n) => n.kind === "instance" && n.name === expected.name, + ); + const passed = definition !== undefined && instances.length === expected.instances; + finalize( + "components", + `components:${expected.name}`, + passed, + expected.expectedFail, + definition === undefined + ? "definition not found" + : instances.length !== expected.instances + ? `expected ${expected.instances} instances, found ${instances.length}` + : undefined, + ); + } + + for (const expected of golden.expect.attributions ?? []) { + const id = `attribution:${expected.component}${expected.instanceAt !== undefined ? `@${expected.instanceAt}` : ""}`; + const found = traceEndpoints(graph, expected.component, expected.instanceAt); + if (found === null) { + attribution.falseNegatives += expected.endpoints.length; + finalize("attributions", id, false, expected.expectedFail, "trace target not found in graph"); + continue; + } + const want = new Set(expected.endpoints); + const got = new Set(found); + const missing = [...want].filter((e) => !got.has(e)); + const extra = [...got].filter((e) => !want.has(e)); + attribution.truePositives += want.size - missing.length; + attribution.falseNegatives += missing.length; + attribution.falsePositives += extra.length; + finalize( + "attributions", + id, + missing.length === 0 && extra.length === 0, + expected.expectedFail, + missing.length + extra.length > 0 + ? `missing: [${missing.join(", ")}] extra: [${extra.join(", ")}]` + : undefined, + ); + } + + for (const forbidden of golden.expect.forbidden ?? []) { + const id = `forbidden:${forbidden.component}${forbidden.instanceAt !== undefined ? `@${forbidden.instanceAt}` : ""}!${forbidden.endpoint}`; + const found = traceEndpoints(graph, forbidden.component, forbidden.instanceAt) ?? []; + const poisoned = found.includes(forbidden.endpoint); + // Forbidden checks never xfail: poison must gate in every phase. + checks.push({ + id, + kind: "forbidden", + status: poisoned ? "fail" : "pass", + detail: poisoned ? `POISON: ${forbidden.note ?? "forbidden attribution present"}` : undefined, + }); + if (poisoned) attribution.falsePositives += 1; + } + + for (const query of golden.expect.queries ?? []) { + const id = `query:${query.terms.join("+")}`; + const result = matchComponentsByText(graph, query.terms); + let passed = result.status === query.status; + let detail: string | undefined; + if (!passed) { + detail = `expected status ${query.status}, got ${result.status}`; + } else if (query.status === "ok" && query.top !== undefined) { + const top = result.candidates[0]?.value.component.name; + passed = top === query.top; + if (!passed) detail = `expected top ${query.top}, got ${top ?? "none"}`; + } + finalize("queries", id, passed, query.expectedFail, detail); + } + + return { fixture, failureMode: golden.failureMode, checks, attribution }; +} + +/** Endpoints reached from a definition or a specific instance. Null if the target is missing. */ +function traceEndpoints( + graph: LineageGraph, + component: string, + instanceAt: string | undefined, +): string[] | null { + const target = + instanceAt !== undefined + ? graph.nodes.find( + (n) => n.kind === "instance" && n.name === component && n.loc.file === instanceAt, + ) + : graph.nodes.find((n) => n.kind === "component" && n.name === component); + if (target === undefined) return null; + const result = traceLineage(graph, target.id); + const lineage = result.candidates[0]?.value; + if (result.status !== "ok" || lineage === undefined) return null; + return lineage.dataSources.map((d) => d.endpoint); +} diff --git a/eval/src/golden.ts b/eval/src/golden.ts new file mode 100644 index 0000000..6dd00b1 --- /dev/null +++ b/eval/src/golden.ts @@ -0,0 +1,97 @@ +/** The golden.json contract every fixture follows. */ + +/** + * xfail semantics, available on any individual check via `expectedFail`: + * a failing check reports xfail (does not gate); a PASSING check still marked + * expectedFail reports unexpected-pass and DOES gate, so stale markers are + * removed the moment the capability lands. + */ +export interface GoldenComponent { + name: string; + /** Expected number of InstanceNodes for this definition. */ + instances: number; + expectedFail?: string; +} + +export interface GoldenAttribution { + component: string; + /** + * When set: trace the *instance* of `component` located in this file. + * When absent: trace the definition. + */ + instanceAt?: string; + /** Exact set of endpoints the trace must yield. */ + endpoints: string[]; + expectedFail?: string; +} + +export interface GoldenForbidden { + component: string; + instanceAt?: string; + /** This endpoint must NOT appear in the trace β€” it is the poison answer. */ + endpoint: string; + note?: string; +} + +export interface GoldenQuery { + terms: string[]; + status: "ok" | "ambiguous" | "declined"; + /** Required top-1 component name when status is "ok". */ + top?: string; + expectedFail?: string; +} + +export interface Golden { + failureMode: string; + note?: string; + /** App directory relative to the fixture dir. Default "./app". */ + app?: string; + expect: { + components?: GoldenComponent[]; + attributions?: GoldenAttribution[]; + forbidden?: GoldenForbidden[]; + queries?: GoldenQuery[]; + }; +} + +export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass"; + +export interface CheckResult { + /** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */ + id: string; + kind: "components" | "attributions" | "forbidden" | "queries"; + status: CheckStatus; + detail?: string; +} + +export interface FixtureResult { + fixture: string; + failureMode: string; + checks: CheckResult[]; + /** Attribution tallies for lineage precision/recall. */ + attribution: { truePositives: number; falsePositives: number; falseNegatives: number }; +} + +export interface Scorecard { + generatedAt: string; + commitSha: string | null; + fixtures: FixtureResult[]; + summary: { + pass: number; + fail: number; + xfail: number; + unexpectedPass: number; + lineagePrecision: number | null; + lineageRecall: number | null; + matchAccuracy: number | null; + }; +} + +export interface Thresholds { + maxFail: number; + maxUnexpectedPass: number; + /** Optional metric floors; null-valued metrics are not gated. */ + minLineagePrecision?: number; + minLineageRecall?: number; + minMatchAccuracy?: number; +} diff --git a/eval/src/run.ts b/eval/src/run.ts new file mode 100644 index 0000000..fae645c --- /dev/null +++ b/eval/src/run.ts @@ -0,0 +1,165 @@ +/** + * CodeRadar eval runner. + * + * Scans every fixture under eval/fixtures/, diffs against golden.json, prints + * a per-failure-mode scorecard, writes eval/scorecard.json, and exits non-zero + * when eval/thresholds.json is violated. + * + * Usage: node eval/dist/run.js [--record] + * --record append this run's summary to eval/history.jsonl (for trend tracking) + */ + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; + +import { runChecks } from "./checks.js"; +import type { FixtureResult, Golden, Scorecard, Thresholds } from "./golden.js"; + +const evalDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixturesDir = path.join(evalDir, "fixtures"); + +function main(): void { + const record = process.argv.includes("--record"); + const fixtureNames = fs + .readdirSync(fixturesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + const results: FixtureResult[] = []; + for (const name of fixtureNames) { + const fixtureDir = path.join(fixturesDir, name); + const goldenPath = path.join(fixtureDir, "golden.json"); + if (!fs.existsSync(goldenPath)) { + console.warn(`skipping ${name}: no golden.json`); + continue; + } + const golden = JSON.parse(fs.readFileSync(goldenPath, "utf-8")) as Golden; + const appDir = path.resolve(fixtureDir, golden.app ?? "./app"); + const graph = resolveHookEdges(scanReact({ root: appDir })); + results.push(runChecks(name, golden, graph)); + } + + const scorecard = buildScorecard(results); + fs.writeFileSync(path.join(evalDir, "scorecard.json"), JSON.stringify(scorecard, null, 2)); + if (record) { + fs.appendFileSync( + path.join(evalDir, "history.jsonl"), + JSON.stringify({ generatedAt: scorecard.generatedAt, commitSha: scorecard.commitSha, ...scorecard.summary }) + "\n", + ); + } + + print(scorecard); + gate(scorecard); +} + +function buildScorecard(results: FixtureResult[]): Scorecard { + let pass = 0; + let fail = 0; + let xfail = 0; + let unexpectedPass = 0; + let queryPass = 0; + let queryTotal = 0; + const attr = { truePositives: 0, falsePositives: 0, falseNegatives: 0 }; + + for (const result of results) { + for (const check of result.checks) { + if (check.status === "pass") pass += 1; + else if (check.status === "fail") fail += 1; + else if (check.status === "xfail") xfail += 1; + else unexpectedPass += 1; + if (check.kind === "queries" && check.status !== "xfail") { + queryTotal += 1; + if (check.status === "pass") queryPass += 1; + } + } + attr.truePositives += result.attribution.truePositives; + attr.falsePositives += result.attribution.falsePositives; + attr.falseNegatives += result.attribution.falseNegatives; + } + + const attributed = attr.truePositives + attr.falsePositives; + const golden = attr.truePositives + attr.falseNegatives; + return { + generatedAt: new Date().toISOString(), + commitSha: gitSha(), + fixtures: results, + summary: { + pass, + fail, + xfail, + unexpectedPass, + lineagePrecision: attributed > 0 ? round(attr.truePositives / attributed) : null, + lineageRecall: golden > 0 ? round(attr.truePositives / golden) : null, + matchAccuracy: queryTotal > 0 ? round(queryPass / queryTotal) : null, + }, + }; +} + +function print(scorecard: Scorecard): void { + const icons = { pass: "βœ“", fail: "βœ—", xfail: "…", "unexpected-pass": "!" } as const; + for (const fixture of scorecard.fixtures) { + console.log(`\n[${fixture.failureMode}] ${fixture.fixture}`); + for (const check of fixture.checks) { + const suffix = check.detail !== undefined ? ` β€” ${check.detail}` : ""; + console.log(` ${icons[check.status]} ${check.id}${suffix}`); + } + } + const s = scorecard.summary; + console.log( + `\n${s.pass} pass Β· ${s.fail} fail Β· ${s.xfail} xfail Β· ${s.unexpectedPass} unexpected-pass`, + ); + console.log( + `lineage precision=${fmt(s.lineagePrecision)} recall=${fmt(s.lineageRecall)} Β· match accuracy=${fmt(s.matchAccuracy)}`, + ); +} + +function gate(scorecard: Scorecard): void { + const thresholds = JSON.parse( + fs.readFileSync(path.join(evalDir, "thresholds.json"), "utf-8"), + ) as Thresholds; + const s = scorecard.summary; + const violations: string[] = []; + if (s.fail > thresholds.maxFail) violations.push(`fail ${s.fail} > max ${thresholds.maxFail}`); + if (s.unexpectedPass > thresholds.maxUnexpectedPass) { + violations.push(`unexpected-pass ${s.unexpectedPass} > max ${thresholds.maxUnexpectedPass}`); + } + checkFloor(violations, "lineagePrecision", s.lineagePrecision, thresholds.minLineagePrecision); + checkFloor(violations, "lineageRecall", s.lineageRecall, thresholds.minLineageRecall); + checkFloor(violations, "matchAccuracy", s.matchAccuracy, thresholds.minMatchAccuracy); + + if (violations.length > 0) { + console.error(`\nEVAL GATE FAILED:\n ${violations.join("\n ")}`); + process.exitCode = 1; + } else { + console.log("\neval gate: OK"); + } +} + +function checkFloor( + violations: string[], + name: string, + value: number | null, + floor: number | undefined, +): void { + if (floor !== undefined && value !== null && value < floor) { + violations.push(`${name} ${value} < min ${floor}`); + } +} + +function gitSha(): string | null { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { cwd: evalDir, encoding: "utf-8" }).trim(); + } catch { + return null; + } +} + +const round = (n: number): number => Math.round(n * 1000) / 1000; +const fmt = (n: number | null): string => (n === null ? "n/a" : n.toFixed(3)); + +main(); diff --git a/eval/thresholds.json b/eval/thresholds.json new file mode 100644 index 0000000..cfebf43 --- /dev/null +++ b/eval/thresholds.json @@ -0,0 +1,5 @@ +{ + "maxFail": 0, + "maxUnexpectedPass": 0, + "minMatchAccuracy": 1.0 +} diff --git a/eval/tsconfig.json b/eval/tsconfig.json new file mode 100644 index 0000000..c17b043 --- /dev/null +++ b/eval/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/package.json b/package.json index b64c690..3f4f410 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "pnpm -r build", "test": "pnpm -r test", "lint": "pnpm -r lint", - "typecheck": "pnpm -r typecheck" + "typecheck": "pnpm -r typecheck", + "eval": "pnpm -r build && node eval/dist/run.js" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eac416..b783df1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,22 @@ importers: .: {} + eval: + dependencies: + '@coderadar/core': + specifier: workspace:* + version: link:../packages/core + '@coderadar/parser-react': + specifier: workspace:* + version: link:../packages/parser-react + devDependencies: + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/cli: dependencies: '@coderadar/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e24990..249a333 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - "packages/*" + - "eval" allowBuilds: esbuild: true From 4676f5ccd477cc032cc6267067162a1ced40a4f3 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 11:14:25 +0530 Subject: [PATCH 04/49] feat(core): graph storage, schema versioning, scan provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0.3 (TRACKER). G2/G3/D1 foundations: - saveGraph/loadGraph: version-checked persistence β€” refuses graphs newer than the library (an old reader must never misinterpret a new graph). - collectGraphMeta: commit SHA + dirty flag recorded at scan time; coderadar scan prints provenance. - schemas/lineage-graph.schema.json generated from the TS types (ts-json-schema-generator), committed, drift-gated by a test that regenerates and diffs. Located at repo root since dist/ is gitignored (TRACKER updated to match). - CLI query commands load through the validating loader: a v99 graph now fails loudly with an upgrade message instead of being silently misread. 24 unit tests green; eval gate OK (25 pass / 0 fail / 2 xfail). Co-Authored-By: Claude Fable 5 --- TRACKER.md | 8 +- package.json | 3 +- packages/cli/src/index.ts | 18 +- packages/core/package.json | 5 +- packages/core/scripts/gen-schema.mjs | 12 + packages/core/scripts/schema-config.mjs | 20 + packages/core/src/index.ts | 1 + packages/core/src/storage.test.ts | 67 ++++ packages/core/src/storage.ts | 63 +++ pnpm-lock.yaml | 110 ++++++ schemas/lineage-graph.schema.json | 504 ++++++++++++++++++++++++ 11 files changed, 802 insertions(+), 9 deletions(-) create mode 100644 packages/core/scripts/gen-schema.mjs create mode 100644 packages/core/scripts/schema-config.mjs create mode 100644 packages/core/src/storage.test.ts create mode 100644 packages/core/src/storage.ts create mode 100644 schemas/lineage-graph.schema.json diff --git a/TRACKER.md b/TRACKER.md index aac2ecb..74f1c6c 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 0 β€” Foundations -- **Next step:** 0.3 β€” Graph storage & versioning -- **Done:** 0.1, 0.2 +- **Next step:** 0.4 β€” CI pipeline +- **Done:** 0.1, 0.2, 0.3 - **Gates passed:** none yet (Gate 0 completes with 0.4) ## What CodeRadar is @@ -67,12 +67,12 @@ The v0.1 schema is definition-only, which is *wrong* for C1 β€” fix before build - Fixtures: `c1-shared-datatable` (DataTable rendered by Users + Invoices pages, different APIs β€” must attribute per instance, `forbidden` catches definition-level attribution), `a4-generic-text` (three components sharing "Save"), plus `demo-app` moved under fixtures. **Accept:** `pnpm eval` runs green locally on the non-C1 assertions; C1 attribution assertions may be *red* (prop-flow lands in 2.2) but the fixture and its golden file exist and the runner reports them as `expected-fail: phase-2`. Expected-fail support is part of the runner. -### [ ] 0.3 Graph storage & versioning +### [x] 0.3 Graph storage & versioning **Failure modes:** G2, G3 (foundation), D1 (foundation) **Build:** - `GraphMeta` β€” `{ commitSha, dirty: boolean, generatedAt, generator, scanRoot }` embedded in `LineageGraph`. - `saveGraph(graph, path)` / `loadGraph(path)` in core with schema-version check (refuse to load a newer major version). -- JSON Schema for the whole graph exported to `dist/schemas/lineage-graph.schema.json` (generated from the TS types; committed; drift-gated by a test that regenerates and diffs). +- JSON Schema for the whole graph exported to `schemas/lineage-graph.schema.json` (repo root β€” `dist/` is gitignored; generated from the TS types; committed; drift-gated by a test that regenerates and diffs). - CLI: `scan` records commit SHA (via `git rev-parse`, `dirty` from `git status`). **Accept:** round-trip test (scan β†’ save β†’ load β†’ deep-equal); schema drift test; `coderadar scan` output includes SHA. diff --git a/package.json b/package.json index 3f4f410..a120629 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "pnpm -r test", "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", - "eval": "pnpm -r build && node eval/dist/run.js" + "eval": "pnpm -r build && node eval/dist/run.js", + "schemas": "pnpm --filter @coderadar/core schema" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9421f87..563bfc9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,9 +4,12 @@ import path from "node:path"; import { type Candidate, + collectGraphMeta, type ComponentMatch, type LineageGraph, + loadGraph as loadGraphFile, matchComponentsByText, + saveGraph, traceLineage, } from "@coderadar/core"; import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; @@ -27,13 +30,17 @@ program .argument("", "directory to scan") .option("-o, --out ", "output file", "coderadar.graph.json") .action((dir: string, opts: { out: string }) => { - const graph = resolveHookEdges(scanReact({ root: dir })); - fs.writeFileSync(opts.out, JSON.stringify(graph, null, 2)); + const meta = collectGraphMeta(path.resolve(dir)); + const graph = { ...resolveHookEdges(scanReact({ root: dir })), meta }; + saveGraph(graph, opts.out); const counts = new Map(); for (const node of graph.nodes) { counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1); } console.log(`Scanned ${path.resolve(dir)}`); + console.log( + ` commit: ${meta.commitSha ?? "not a git repo"}${meta.dirty ? " (dirty working tree)" : ""}`, + ); for (const [kind, count] of [...counts].sort()) console.log(` ${kind}: ${count}`); console.log(` edges: ${graph.edges.length}`); console.log(`Graph written to ${opts.out}`); @@ -127,7 +134,12 @@ function loadGraph(file: string): LineageGraph { console.error(`Graph file not found: ${file} β€” run \`coderadar scan \` first.`); process.exit(1); } - return JSON.parse(fs.readFileSync(file, "utf-8")) as LineageGraph; + try { + return loadGraphFile(file); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } program.parse(); diff --git a/packages/core/package.json b/packages/core/package.json index e479749..da0344a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,9 +18,12 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" + "test": "vitest run", + "schema": "node scripts/gen-schema.mjs" }, "devDependencies": { + "@types/node": "^22.20.1", + "ts-json-schema-generator": "^2.9.0", "typescript": "^5.7.0", "vitest": "^3.2.7" } diff --git a/packages/core/scripts/gen-schema.mjs b/packages/core/scripts/gen-schema.mjs new file mode 100644 index 0000000..c426076 --- /dev/null +++ b/packages/core/scripts/gen-schema.mjs @@ -0,0 +1,12 @@ +/** Regenerate schemas/lineage-graph.schema.json from the TS types. */ +import fs from "node:fs"; +import path from "node:path"; + +import { createGenerator } from "ts-json-schema-generator"; + +import { generatorConfig, schemaOutPath } from "./schema-config.mjs"; + +const schema = createGenerator(generatorConfig).createSchema(generatorConfig.type); +fs.mkdirSync(path.dirname(schemaOutPath), { recursive: true }); +fs.writeFileSync(schemaOutPath, JSON.stringify(schema, null, 2) + "\n"); +console.log(`wrote ${schemaOutPath}`); diff --git a/packages/core/scripts/schema-config.mjs b/packages/core/scripts/schema-config.mjs new file mode 100644 index 0000000..a41e4e4 --- /dev/null +++ b/packages/core/scripts/schema-config.mjs @@ -0,0 +1,20 @@ +/** + * Shared config for JSON Schema generation β€” used by gen-schema.mjs (writes + * the committed file) and the drift-gate test (regenerates and diffs). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** ts-json-schema-generator config for the LineageGraph root type. */ +export const generatorConfig = { + path: path.join(packageDir, "src/types.ts"), + tsconfig: path.join(packageDir, "tsconfig.json"), + type: "LineageGraph", + topRef: true, + additionalProperties: false, +}; + +/** Committed schema location (repo root β€” dist/ is gitignored). */ +export const schemaOutPath = path.resolve(packageDir, "../../schemas/lineage-graph.schema.json"); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4362be2..55f6be9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./result.js"; export * from "./query.js"; +export * from "./storage.js"; diff --git a/packages/core/src/storage.test.ts b/packages/core/src/storage.test.ts new file mode 100644 index 0000000..8e8f3b2 --- /dev/null +++ b/packages/core/src/storage.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createGenerator } from "ts-json-schema-generator"; +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 type { LineageGraph } from "./types.js"; + +const tmp = (name: string) => path.join(fs.mkdtempSync(path.join(os.tmpdir(), "coderadar-")), name); + +const graph: LineageGraph = { + version: 2, + root: "/scanned/app", + generatedAt: "2026-01-01T00:00:00Z", + generator: "test", + meta: { commitSha: "abc123", dirty: false }, + nodes: [], + edges: [], +}; + +describe("saveGraph / loadGraph", () => { + it("round-trips deep-equal", () => { + const file = tmp("graph.json"); + saveGraph(graph, file); + expect(loadGraph(file)).toEqual(graph); + }); + + it("refuses a graph newer than this library", () => { + const file = tmp("future.json"); + fs.writeFileSync(file, JSON.stringify({ ...graph, version: SCHEMA_VERSION + 1 })); + expect(() => loadGraph(file)).toThrow(/upgrade @coderadar\/core/); + }); + + it("refuses files that are not lineage graphs", () => { + const file = tmp("junk.json"); + fs.writeFileSync(file, JSON.stringify({ hello: "world" })); + expect(() => loadGraph(file)).toThrow(/no version field/); + + const badVersion = tmp("badversion.json"); + fs.writeFileSync(badVersion, JSON.stringify({ version: "two" })); + expect(() => loadGraph(badVersion)).toThrow(/version must be a number/); + }); +}); + +describe("collectGraphMeta", () => { + it("returns a SHA inside a git repo", () => { + const meta = collectGraphMeta(path.dirname(schemaOutPath)); + expect(meta.commitSha).toMatch(/^[0-9a-f]{40}$/); + }); + + it("returns null SHA outside a git repo", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coderadar-nogit-")); + expect(collectGraphMeta(dir).commitSha).toBeNull(); + }); +}); + +describe("JSON Schema drift gate", () => { + it("committed schema matches the current TS types β€” run `pnpm --filter @coderadar/core schema` after schema changes", () => { + const generated = createGenerator(generatorConfig).createSchema(generatorConfig.type); + const committed: unknown = JSON.parse(fs.readFileSync(schemaOutPath, "utf-8")); + expect(committed).toEqual(JSON.parse(JSON.stringify(generated))); + }); +}); diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts new file mode 100644 index 0000000..51a86f2 --- /dev/null +++ b/packages/core/src/storage.ts @@ -0,0 +1,63 @@ +/** + * Graph persistence with schema-version checking and scan provenance. + * + * A stored graph is the contract between the scan side (CI job, CLI) and the + * query side (agent SDK, MCP server) β€” possibly running months apart on + * different machines. Version checks refuse graphs newer than this library; + * GraphMeta records exactly which code was scanned (G2/G3 foundations). + */ + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; + +import type { GraphMeta, LineageGraph } from "./types.js"; + +/** The schema version this library reads and writes. */ +export const SCHEMA_VERSION = 2; + +export function saveGraph(graph: LineageGraph, filePath: string): void { + fs.writeFileSync(filePath, JSON.stringify(graph, null, 2)); +} + +/** + * Load and validate a stored graph. + * + * Throws on: unreadable/invalid JSON, missing version, or a version newer + * than this library (an older reader must not misinterpret a newer graph β€” + * upgrade the library instead). Older versions are accepted; migrations are + * added here when version 3 exists. + */ +export function loadGraph(filePath: string): LineageGraph { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { + throw new Error(`${filePath} is not a CodeRadar lineage graph (no version field)`); + } + const version = (parsed as { version: unknown }).version; + if (typeof version !== "number") { + throw new Error(`${filePath}: version must be a number, got ${typeof version}`); + } + if (version > SCHEMA_VERSION) { + throw new Error( + `${filePath} uses schema v${version}, but this library reads up to v${SCHEMA_VERSION} β€” upgrade @coderadar/core`, + ); + } + return parsed as LineageGraph; +} + +/** Collect scan provenance for a directory: commit SHA + dirty flag. */ +export function collectGraphMeta(scanRoot: string): GraphMeta { + try { + const commitSha = execFileSync("git", ["-C", scanRoot, "rev-parse", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const status = execFileSync("git", ["-C", scanRoot, "status", "--porcelain"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + return { commitSha, dirty: status.trim().length > 0 }; + } catch { + return { commitSha: null, dirty: false }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b783df1..cce41e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: packages/core: devDependencies: + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + ts-json-schema-generator: + specifier: ^2.9.0 + version: 2.9.0 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -382,6 +388,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -421,9 +430,17 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -443,6 +460,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -485,19 +506,40 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -506,9 +548,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -532,6 +582,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -570,9 +624,17 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + ts-json-schema-generator@2.9.0: + resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} + engines: {node: '>=22.0.0'} + hasBin: true + ts-morph@24.0.0: resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -831,6 +893,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -881,10 +945,16 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + cac@6.7.14: {} chai@5.3.3: @@ -901,6 +971,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -951,24 +1023,47 @@ snapshots: fsevents@2.3.3: optional: true + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + js-tokens@9.0.1: {} + json5@2.2.3: {} + loupe@3.2.1: {} + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.2 + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.15: {} + normalize-path@3.0.0: {} + path-browserify@1.0.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1014,6 +1109,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + safe-stable-stringify@2.5.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1041,11 +1138,24 @@ snapshots: tinyspy@4.0.4: {} + ts-json-schema-generator@2.9.0: + dependencies: + '@types/json-schema': 7.0.15 + commander: 14.0.3 + glob: 13.0.6 + json5: 2.2.3 + normalize-path: 3.0.0 + safe-stable-stringify: 2.5.0 + tslib: 2.8.1 + typescript: 5.9.3 + ts-morph@24.0.0: dependencies: '@ts-morph/common': 0.25.0 code-block-writer: 13.0.3 + tslib@2.8.1: {} + typescript@5.9.3: {} undici-types@6.21.0: {} diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json new file mode 100644 index 0000000..c3ba0c5 --- /dev/null +++ b/schemas/lineage-graph.schema.json @@ -0,0 +1,504 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/LineageGraph", + "definitions": { + "LineageGraph": { + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 2, + "description": "Schema version for forward compatibility." + }, + "root": { + "type": "string", + "description": "Absolute path of the scanned root at generation time." + }, + "generatedAt": { + "type": "string" + }, + "generator": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/GraphMeta" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/definitions/LineageNode" + } + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/definitions/LineageEdge" + } + } + }, + "required": [ + "version", + "root", + "generatedAt", + "generator", + "nodes", + "edges" + ], + "additionalProperties": false + }, + "GraphMeta": { + "type": "object", + "properties": { + "commitSha": { + "type": [ + "string", + "null" + ], + "description": "Commit SHA of the scanned tree; null when not a git repo." + }, + "dirty": { + "type": "boolean", + "description": "True when the working tree had uncommitted changes at scan time." + } + }, + "required": [ + "commitSha", + "dirty" + ], + "additionalProperties": false, + "description": "Scan provenance β€” which code this graph describes." + }, + "LineageNode": { + "anyOf": [ + { + "$ref": "#/definitions/ComponentNode" + }, + { + "$ref": "#/definitions/InstanceNode" + }, + { + "$ref": "#/definitions/HookNode" + }, + { + "$ref": "#/definitions/DataSourceNode" + }, + { + "$ref": "#/definitions/StateNode" + }, + { + "$ref": "#/definitions/EventNode" + } + ] + }, + "ComponentNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "component" + }, + "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." + }, + "exportName": { + "type": [ + "string", + "null" + ], + "description": "Named or default export, if exported." + }, + "props": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Prop names destructured or accessed from the props object." + }, + "renderedText": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Static text visible in the rendered output (JSX text, string literals in attributes like placeholder/label/title/alt/aria-label). This is the primary signal for matching a screenshot to a component." + }, + "rendersComponents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of components this component renders in its JSX (deduplicated)." + } + }, + "required": [ + "exportName", + "id", + "kind", + "loc", + "name", + "props", + "renderedText", + "rendersComponents" + ], + "additionalProperties": false, + "description": "A React component definition β€” the code, not a usage." + }, + "SourceLocation": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Path relative to the scan root, POSIX separators." + }, + "line": { + "type": "number", + "description": "1-based line of the declaration." + }, + "endLine": { + "type": "number", + "description": "1-based end line of the declaration." + } + }, + "required": [ + "file", + "line", + "endLine" + ], + "additionalProperties": false, + "description": "Where a node lives in the codebase." + }, + "InstanceNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "instance" + }, + "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." + }, + "definitionId": { + "type": "string", + "description": "Id of the ComponentNode this instantiates." + }, + "parentInstanceId": { + "type": [ + "string", + "null" + ], + "description": "Enclosing instance in the render tree. Null until the cross-file instance tree is built (Phase 2.1); the enclosing *definition* is available via the incoming `renders` edge meanwhile." + }, + "staticProps": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Props with statically-known string values at this call site." + } + }, + "required": [ + "definitionId", + "id", + "kind", + "loc", + "name", + "parentInstanceId", + "staticProps" + ], + "additionalProperties": false, + "description": "A component as rendered at one specific call site.\n\nThe same definition rendered on the Users page and the Invoices page yields two instances β€” and per-instance data attribution (Phase 2.2) is what lets each report a different API." + }, + "HookNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "hook" + }, + "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." + }, + "exportName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "exportName", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "A custom hook β€” often the bridge between a component and its data." + }, + "DataSourceNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "data-source" + }, + "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." + }, + "sourceKind": { + "$ref": "#/definitions/DataSourceKind" + }, + "method": { + "type": [ + "string", + "null" + ], + "description": "HTTP method when statically determinable." + }, + "endpoint": { + "type": "string", + "description": "The endpoint as written in source β€” may contain template placeholders, e.g. \"/api/users/${id}\"." + } + }, + "required": [ + "endpoint", + "id", + "kind", + "loc", + "method", + "name", + "sourceKind" + ], + "additionalProperties": false, + "description": "An external data origin: an HTTP endpoint, GraphQL operation, or socket." + }, + "DataSourceKind": { + "type": "string", + "enum": [ + "fetch", + "axios", + "react-query", + "swr", + "graphql", + "websocket", + "unknown" + ] + }, + "StateNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "state" + }, + "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." + }, + "stateKind": { + "$ref": "#/definitions/StateKind" + } + }, + "required": [ + "id", + "kind", + "loc", + "name", + "stateKind" + ], + "additionalProperties": false, + "description": "Local or global state a component reads." + }, + "StateKind": { + "type": "string", + "enum": [ + "useState", + "useReducer", + "context", + "redux", + "zustand", + "unknown" + ] + }, + "EventNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "event" + }, + "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." + }, + "event": { + "type": "string", + "description": "e.g. \"onClick\", \"onSubmit\", \"onChange\"" + }, + "handler": { + "type": [ + "string", + "null" + ], + "description": "Name of the handler function, if resolvable." + } + }, + "required": [ + "event", + "handler", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "A user or system event a component responds to." + }, + "LineageEdge": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/EdgeKind" + }, + "via": { + "type": "string", + "description": "Prop name for provides-data edges; handler name for triggers." + }, + "condition": { + "$ref": "#/definitions/EdgeCondition" + } + }, + "required": [ + "from", + "to", + "kind" + ], + "additionalProperties": false + }, + "EdgeKind": { + "type": "string", + "enum": [ + "renders", + "instance-of", + "uses-hook", + "fetches-from", + "provides-data", + "reads-state", + "writes-state", + "handles", + "triggers" + ] + }, + "EdgeCondition": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "flag", + "role", + "branch", + "response" + ] + }, + "expression": { + "type": "string", + "description": "Source text of the condition, e.g. `isEnabled(\"new-billing\")`." + } + }, + "required": [ + "kind", + "expression" + ], + "additionalProperties": false, + "description": "A statically-detected condition guarding an edge (feature flag, role, branch)." + } + } +} From 2fb4292b5d7f5410685398c5e3b2d2e9176f0d17 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 12:12:37 +0530 Subject: [PATCH 05/49] =?UTF-8?q?ci:=20Gate=200=20pipeline=20=E2=80=94=20b?= =?UTF-8?q?uild,=20typecheck,=20tests,=20eval=20gate,=20scorecard=20artifa?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0.4 (TRACKER). ubuntu-latest, pnpm-cached, 15-min timeout, concurrency-cancelled. Scorecard uploaded on every run (pass or fail). Threshold ratchet rule documented in the workflow header: raising eval/thresholds.json is free, lowering requires PR-body justification. Full step sequence verified locally (frozen install, build, typecheck, 24 tests, eval gate OK). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ TRACKER.md | 6 ++--- 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d97954d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +# CodeRadar CI β€” the Gate 0 pipeline. +# +# Every PR must pass: build (strict TS), typecheck, unit tests, and the eval +# gate (eval/thresholds.json). The scorecard is uploaded as an artifact on +# every run, pass or fail, so regressions are inspectable. +# +# Threshold ratchet rule (docs/testing-strategy.md): eval/thresholds.json +# values may be RAISED in any PR; LOWERING one requires a written +# justification in the PR body. Thresholds never loosen silently. + +name: CI + +on: + push: + branches: [main, development] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 # reads the packageManager field + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Build (strict TS) + run: pnpm build + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests + run: pnpm test + + - name: Eval gate + run: node eval/dist/run.js + + - name: Upload scorecard + if: always() + uses: actions/upload-artifact@v4 + with: + name: eval-scorecard + path: eval/scorecard.json + if-no-files-found: ignore diff --git a/TRACKER.md b/TRACKER.md index 74f1c6c..3149476 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 0 β€” Foundations -- **Next step:** 0.4 β€” CI pipeline -- **Done:** 0.1, 0.2, 0.3 +- **Next step:** 1.1 β€” Endpoint resolution (constants, templates, patterns) +- **Done:** 0.1, 0.2, 0.3, 0.4 - **Gates passed:** none yet (Gate 0 completes with 0.4) ## What CodeRadar is @@ -76,7 +76,7 @@ The v0.1 schema is definition-only, which is *wrong* for C1 β€” fix before build - CLI: `scan` records commit SHA (via `git rev-parse`, `dirty` from `git status`). **Accept:** round-trip test (scan β†’ save β†’ load β†’ deep-equal); schema drift test; `coderadar scan` output includes SHA. -### [ ] 0.4 CI pipeline +### [x] 0.4 CI pipeline **Failure modes:** D1, process **Build:** GitHub Actions workflow: install (pnpm cache) β†’ build β†’ typecheck β†’ vitest unit tests β†’ `pnpm eval` β†’ upload scorecard as artifact. Threshold ratchet rule documented in the workflow file header (raising OK; lowering needs PR-body justification). **Accept:** CI green on a PR; a deliberately broken fixture in a test branch turns it red. **Gate 0 passes.** From d17f89492ce8c1529e1a49131dd219d94f43e6c8 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:15:35 +0530 Subject: [PATCH 06/49] =?UTF-8?q?feat(parser-react):=20endpoint=20resoluti?= =?UTF-8?q?on=20=E2=80=94=20constant=20folding,=20route=20patterns,=20base?= =?UTF-8?q?-URL=20stripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.1 (TRACKER). Failure modes C2 (constants half), C3: - resolveEndpoint/resolveStringValue: cross-file constant folding via go-to-definition (named constants, ENDPOINTS.X object members, + concat, as/satisfies unwrap, depth-capped), template literals with unknown parts normalized to :param placeholders (/api/users/${user.id} β†’ /api/users/:id), configured baseUrls stripped from resolved endpoints. - DataSourceNode gains raw (source text) + resolved (full|partial|none); endpoint is now the canonical pattern β€” the cross-graph join key. - Fixtures c2-endpoint-constants (object member / named const / concat) and c3-dynamic-endpoints (dynamic id + dynamic resource segment), both with -collapse forbidden assertions. - Thresholds ratcheted: minLineagePrecision 0.9, minLineageRecall 0.8. Scorecard: 38 pass / 0 fail / 2 xfail; precision 1.000, recall 0.833 (was 0.714), match accuracy 1.000. 33 unit tests. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 10 +- .../c2-endpoint-constants/app/HealthBadge.tsx | 15 ++ .../app/ReportsPanel.tsx | 24 +++ .../c2-endpoint-constants/app/UsersPanel.tsx | 24 +++ .../app/api/endpoints.ts | 8 + .../c2-endpoint-constants/golden.json | 27 +++ .../c3-dynamic-endpoints/app/OrderDetail.tsx | 27 +++ .../fixtures/c3-dynamic-endpoints/golden.json | 21 +++ eval/fixtures/demo-app/golden.json | 4 +- eval/history.jsonl | 1 + eval/thresholds.json | 4 +- packages/core/src/types.ts | 14 +- packages/parser-react/src/endpoint.test.ts | 73 ++++++++ packages/parser-react/src/endpoint.ts | 166 ++++++++++++++++++ packages/parser-react/src/scan.test.ts | 15 +- packages/parser-react/src/scan.ts | 39 +++- schemas/lineage-graph.schema.json | 20 ++- 17 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx create mode 100644 eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx create mode 100644 eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx create mode 100644 eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts create mode 100644 eval/fixtures/c2-endpoint-constants/golden.json create mode 100644 eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx create mode 100644 eval/fixtures/c3-dynamic-endpoints/golden.json create mode 100644 packages/parser-react/src/endpoint.test.ts create mode 100644 packages/parser-react/src/endpoint.ts diff --git a/TRACKER.md b/TRACKER.md index 3149476..0899814 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -4,10 +4,10 @@ ## Status -- **Current phase:** 0 β€” Foundations -- **Next step:** 1.1 β€” Endpoint resolution (constants, templates, patterns) -- **Done:** 0.1, 0.2, 0.3, 0.4 -- **Gates passed:** none yet (Gate 0 completes with 0.4) +- **Current phase:** 1 β€” Robust extraction +- **Next step:** 1.2 β€” API-client wrapper adapter +- **Done:** 0.1–0.4, 1.1 +- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) ## What CodeRadar is @@ -88,7 +88,7 @@ The v0.1 schema is definition-only, which is *wrong* for C1 β€” fix before build Make the parser survive real code instead of demo code. All work is within-file or follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. -### [ ] 1.1 Endpoint resolution β€” constants, templates, patterns +### [x] 1.1 Endpoint resolution β€” constants, templates, patterns **Failure modes:** C2 (constants half), C3 **Build:** in `parser-react`: - Resolve identifier arguments to fetch/axios through imports to their declarations (constant folding: string literals, `const` object members like `ENDPOINTS.USERS`, simple concatenation). diff --git a/eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx b/eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx new file mode 100644 index 0000000..d885add --- /dev/null +++ b/eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +import { HEALTH_URL } from "./api/endpoints"; + +export function HealthBadge() { + const [status, setStatus] = useState("unknown"); + + useEffect(() => { + fetch(HEALTH_URL) + .then((res) => res.json()) + .then((body: { status: string }) => setStatus(body.status)); + }, []); + + return {status}; +} diff --git a/eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx b/eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx new file mode 100644 index 0000000..db9a36a --- /dev/null +++ b/eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +import { API_PREFIX } from "./api/endpoints"; + +export function ReportsPanel() { + const [reports, setReports] = useState([]); + + useEffect(() => { + fetch(API_PREFIX + "/reports") + .then((res) => res.json()) + .then(setReports); + }, []); + + return ( +
+

Weekly reports

+
    + {reports.map((r) => ( +
  • {r}
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx b/eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx new file mode 100644 index 0000000..63c6fbb --- /dev/null +++ b/eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +import { ENDPOINTS } from "./api/endpoints"; + +export function UsersPanel() { + const [users, setUsers] = useState([]); + + useEffect(() => { + fetch(ENDPOINTS.USERS) + .then((res) => res.json()) + .then(setUsers); + }, []); + + return ( +
+

User Directory

+
    + {users.map((u) => ( +
  • {u}
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts b/eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts new file mode 100644 index 0000000..93eadb1 --- /dev/null +++ b/eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts @@ -0,0 +1,8 @@ +export const ENDPOINTS = { + USERS: "/api/users", + INVOICES: "/api/invoices", +}; + +export const HEALTH_URL = "/api/health"; + +export const API_PREFIX = "/api"; diff --git a/eval/fixtures/c2-endpoint-constants/golden.json b/eval/fixtures/c2-endpoint-constants/golden.json new file mode 100644 index 0000000..bfe209a --- /dev/null +++ b/eval/fixtures/c2-endpoint-constants/golden.json @@ -0,0 +1,27 @@ +{ + "failureMode": "C2", + "note": "Endpoints hidden behind constants: an object member (ENDPOINTS.USERS), a plain named constant (HEALTH_URL), and a concatenation (API_PREFIX + '/reports') β€” all declared in a different file than the fetch. Constant folding must recover the literal endpoint.", + "expect": { + "components": [ + { "name": "UsersPanel", "instances": 0 }, + { "name": "HealthBadge", "instances": 0 }, + { "name": "ReportsPanel", "instances": 0 } + ], + "attributions": [ + { "component": "UsersPanel", "endpoints": ["/api/users"] }, + { "component": "HealthBadge", "endpoints": ["/api/health"] }, + { "component": "ReportsPanel", "endpoints": ["/api/reports"] } + ], + "forbidden": [ + { + "component": "UsersPanel", + "endpoint": "", + "note": "constant-hidden endpoint reported as dynamic means folding regressed" + } + ], + "queries": [ + { "terms": ["User Directory"], "status": "ok", "top": "UsersPanel" }, + { "terms": ["Weekly reports"], "status": "ok", "top": "ReportsPanel" } + ] + } +} diff --git a/eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx b/eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx new file mode 100644 index 0000000..52b3ebb --- /dev/null +++ b/eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +export function OrderDetail({ orderId, entity }: { orderId: string; entity: string }) { + const [order, setOrder] = useState | null>(null); + const [related, setRelated] = useState([]); + + useEffect(() => { + fetch(`/api/orders/${orderId}`) + .then((res) => res.json()) + .then(setOrder); + fetch(`/api/${entity}/list`) + .then((res) => res.json()) + .then(setRelated); + }, [orderId, entity]); + + return ( +
+

Order detail

+

{order?.status}

+
    + {related.map((r) => ( +
  • {r}
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/c3-dynamic-endpoints/golden.json b/eval/fixtures/c3-dynamic-endpoints/golden.json new file mode 100644 index 0000000..857ec08 --- /dev/null +++ b/eval/fixtures/c3-dynamic-endpoints/golden.json @@ -0,0 +1,21 @@ +{ + "failureMode": "C3", + "note": "Runtime-valued endpoints: `/api/orders/${orderId}` must resolve to the pattern /api/orders/:orderId (partial), and `/api/${entity}/list` β€” where even the resource segment is dynamic β€” to /api/:entity/list. The shape is preserved, the unknowns are flagged, and nothing collapses to .", + "expect": { + "components": [{ "name": "OrderDetail", "instances": 0 }], + "attributions": [ + { + "component": "OrderDetail", + "endpoints": ["/api/orders/:orderId", "/api/:entity/list"] + } + ], + "forbidden": [ + { + "component": "OrderDetail", + "endpoint": "", + "note": "template endpoints with known shape must not collapse to dynamic" + } + ], + "queries": [{ "terms": ["Order detail"], "status": "ok", "top": "OrderDetail" }] + } +} diff --git a/eval/fixtures/demo-app/golden.json b/eval/fixtures/demo-app/golden.json index c027f35..48c24c2 100644 --- a/eval/fixtures/demo-app/golden.json +++ b/eval/fixtures/demo-app/golden.json @@ -10,11 +10,11 @@ "attributions": [ { "component": "UserList", - "endpoints": ["/api/users", "/api/users/${user.id}"] + "endpoints": ["/api/users", "/api/users/:id"] }, { "component": "UserCard", - "endpoints": ["/api/users/${user.id}"] + "endpoints": ["/api/users/:id"] } ], "queries": [ diff --git a/eval/history.jsonl b/eval/history.jsonl index 6dc53e8..0840169 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -1 +1,2 @@ {"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1} +{"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1} diff --git a/eval/thresholds.json b/eval/thresholds.json index cfebf43..01e8e4c 100644 --- a/eval/thresholds.json +++ b/eval/thresholds.json @@ -1,5 +1,7 @@ { "maxFail": 0, "maxUnexpectedPass": 0, - "minMatchAccuracy": 1.0 + "minMatchAccuracy": 1.0, + "minLineagePrecision": 0.9, + "minLineageRecall": 0.8 } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index faeb3ce..af0ebff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -95,6 +95,12 @@ export type DataSourceKind = | "websocket" | "unknown"; +/** How much of an endpoint was statically resolvable. */ +export type EndpointResolution = + | "full" // every segment is a known string + | "partial" // known shape with :param placeholders, e.g. "/api/users/:id" + | "none"; // nothing statically known ("") + /** An external data origin: an HTTP endpoint, GraphQL operation, or socket. */ export interface DataSourceNode extends BaseNode { kind: "data-source"; @@ -102,10 +108,14 @@ export interface DataSourceNode extends BaseNode { /** HTTP method when statically determinable. */ method: string | null; /** - * The endpoint as written in source β€” may contain template placeholders, - * e.g. "/api/users/${id}". + * Canonical endpoint pattern: constants folded, template placeholders + * normalized to :param form β€” e.g. "/api/users/:id". This is the value + * attribution and cross-graph joins match on. */ endpoint: string; + /** The endpoint expression exactly as written in source. */ + raw: string; + resolved: EndpointResolution; } export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown"; diff --git a/packages/parser-react/src/endpoint.test.ts b/packages/parser-react/src/endpoint.test.ts new file mode 100644 index 0000000..ea68687 --- /dev/null +++ b/packages/parser-react/src/endpoint.test.ts @@ -0,0 +1,73 @@ +import { type Node, Project, SyntaxKind } from "ts-morph"; +import { describe, expect, it } from "vitest"; + +import { resolveEndpoint } from "./endpoint.js"; + +/** Parse a snippet and return the first argument of the fetch(...) call in it. */ +function fetchArg(code: string): Node | undefined { + const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } }); + const sourceFile = project.createSourceFile("test.ts", code); + const call = sourceFile + .getDescendantsOfKind(SyntaxKind.CallExpression) + .find((c) => c.getExpression().getText() === "fetch"); + return call?.getArguments()[0]; +} + +describe("resolveEndpoint", () => { + it("resolves plain literals as full", () => { + expect(resolveEndpoint(fetchArg(`fetch("/api/a");`), [])).toEqual({ + endpoint: "/api/a", + raw: `"/api/a"`, + resolved: "full", + }); + }); + + it("folds a named constant", () => { + const arg = fetchArg(`const USERS = "/api/users"; fetch(USERS);`); + expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/users", resolved: "full" }); + }); + + it("folds an object member (ENDPOINTS.USERS)", () => { + const arg = fetchArg(`const E = { USERS: "/api/users" }; fetch(E.USERS);`); + expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/users", resolved: "full" }); + }); + + it("folds concatenation of constant + literal", () => { + const arg = fetchArg(`const P = "/api"; fetch(P + "/reports");`); + expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/reports", resolved: "full" }); + }); + + it("folds a fully-resolvable template", () => { + const arg = fetchArg(`const V = "v2"; fetch(\`/api/\${V}/users\`);`); + expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/v2/users", resolved: "full" }); + }); + + it("turns unknown template parts into :param placeholders", () => { + const arg = fetchArg(`declare const user: { id: string }; fetch(\`/api/users/\${user.id}\`);`); + expect(resolveEndpoint(arg, [])).toMatchObject({ + endpoint: "/api/users/:id", + resolved: "partial", + }); + }); + + it("keeps the shape when the resource segment itself is dynamic", () => { + const arg = fetchArg(`declare const entity: string; fetch(\`/api/\${entity}/list\`);`); + expect(resolveEndpoint(arg, [])).toMatchObject({ + endpoint: "/api/:entity/list", + resolved: "partial", + }); + }); + + it("reports none for fully-opaque expressions", () => { + const arg = fetchArg(`declare function buildUrl(): string; fetch(buildUrl());`); + expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "", resolved: "none" }); + }); + + it("strips configured base URLs", () => { + const arg = fetchArg(`fetch("https://api.example.com/users");`); + expect(resolveEndpoint(arg, ["https://api.example.com"])).toMatchObject({ + endpoint: "/users", + resolved: "full", + }); + }); +}); diff --git a/packages/parser-react/src/endpoint.ts b/packages/parser-react/src/endpoint.ts new file mode 100644 index 0000000..314cc0a --- /dev/null +++ b/packages/parser-react/src/endpoint.ts @@ -0,0 +1,166 @@ +/** + * Static endpoint resolution (TRACKER step 1.1, failure modes C2/C3). + * + * Turns the expression passed to fetch/axios/useSWR into a canonical endpoint + * pattern: constants folded across files, template placeholders normalized to + * :param form, configured base-URL prefixes stripped. + * + * fetch(ENDPOINTS.USERS) β†’ "/api/users" (full) + * fetch(`/api/orders/${orderId}`) β†’ "/api/orders/:orderId" (partial) + * fetch(base + "/reports") β†’ "/api/reports" (full, if base folds) + * fetch(buildUrl()) β†’ "" (none) + */ + +import type { EndpointResolution } from "@coderadar/core"; +import { Node, SyntaxKind } from "ts-morph"; + +export interface ResolvedEndpoint { + endpoint: string; + raw: string; + resolved: EndpointResolution; +} + +const MAX_FOLD_DEPTH = 6; + +export function resolveEndpoint(node: Node | undefined, baseUrls: string[]): ResolvedEndpoint { + if (node === undefined) { + return { endpoint: "", raw: "", resolved: "none" }; + } + const raw = node.getText(); + + const full = resolveStringValue(node, 0); + if (full !== null) { + return { endpoint: stripBaseUrls(full, baseUrls), raw, resolved: "full" }; + } + + const partial = resolvePattern(node); + if (partial !== null) { + return { endpoint: stripBaseUrls(partial, baseUrls), raw, resolved: "partial" }; + } + + return { endpoint: "", raw, resolved: "none" }; +} + +/** + * Fold an expression to a known string: literals, cross-file constants + * (via go-to-definition), object members (ENDPOINTS.USERS), `+` concatenation, + * and fully-resolvable templates. Null when any part is unknown. + */ +export function resolveStringValue(node: Node | undefined, depth: number): string | null { + if (node === undefined || depth > MAX_FOLD_DEPTH) return null; + + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { + return node.getLiteralValue(); + } + if ( + Node.isAsExpression(node) || + Node.isSatisfiesExpression(node) || + Node.isParenthesizedExpression(node) + ) { + return resolveStringValue(node.getExpression(), depth + 1); + } + if (Node.isIdentifier(node)) { + for (const definition of node.getDefinitionNodes()) { + if (Node.isVariableDeclaration(definition)) { + const value = resolveStringValue(definition.getInitializer(), depth + 1); + if (value !== null) return value; + } + } + return null; + } + if (Node.isPropertyAccessExpression(node)) { + const objectLiteral = resolveObjectLiteral(node.getExpression(), depth + 1); + const property = objectLiteral?.getProperty(node.getName()); + if (property !== undefined && Node.isPropertyAssignment(property)) { + return resolveStringValue(property.getInitializer(), depth + 1); + } + return null; + } + if ( + Node.isBinaryExpression(node) && + node.getOperatorToken().getKind() === SyntaxKind.PlusToken + ) { + const left = resolveStringValue(node.getLeft(), depth + 1); + const right = resolveStringValue(node.getRight(), depth + 1); + return left !== null && right !== null ? left + right : null; + } + if (Node.isTemplateExpression(node)) { + let out = node.getHead().getLiteralText(); + for (const span of node.getTemplateSpans()) { + const value = resolveStringValue(span.getExpression(), depth + 1); + if (value === null) return null; + out += value + span.getLiteral().getLiteralText(); + } + return out; + } + return null; +} + +/** Pattern form with :param placeholders for the unresolvable parts. */ +function resolvePattern(node: Node): string | null { + if (Node.isTemplateExpression(node)) { + let out = node.getHead().getLiteralText(); + for (const span of node.getTemplateSpans()) { + const value = resolveStringValue(span.getExpression(), 0); + out += value ?? `:${placeholderName(span.getExpression())}`; + out += span.getLiteral().getLiteralText(); + } + return out; + } + if ( + Node.isBinaryExpression(node) && + node.getOperatorToken().getKind() === SyntaxKind.PlusToken + ) { + const left = patternPart(node.getLeft()); + const right = patternPart(node.getRight()); + // A concatenation where *nothing* resolved carries no shape β€” report none. + if (left.startsWith(":") && right.startsWith(":")) return null; + return left + right; + } + return null; +} + +function patternPart(node: Node): string { + const value = resolveStringValue(node, 0); + if (value !== null) return value; + const pattern = resolvePattern(node); + if (pattern !== null) return pattern; + return `:${placeholderName(node)}`; +} + +/** ":id" for `user.id`, ":orderId" for `orderId`, ":param" for anything opaque. */ +function placeholderName(node: Node): string { + const match = /([A-Za-z_$][\w$]*)\s*$/.exec(node.getText()); + return match?.[1] ?? "param"; +} + +function resolveObjectLiteral(node: Node | undefined, depth: number): import("ts-morph").ObjectLiteralExpression | null { + if (node === undefined || depth > MAX_FOLD_DEPTH) return null; + if (Node.isObjectLiteralExpression(node)) return node; + if ( + Node.isAsExpression(node) || + Node.isSatisfiesExpression(node) || + Node.isParenthesizedExpression(node) + ) { + return resolveObjectLiteral(node.getExpression(), depth + 1); + } + if (Node.isIdentifier(node)) { + for (const definition of node.getDefinitionNodes()) { + if (Node.isVariableDeclaration(definition)) { + const literal = resolveObjectLiteral(definition.getInitializer(), depth + 1); + if (literal !== null) return literal; + } + } + } + return null; +} + +function stripBaseUrls(endpoint: string, baseUrls: string[]): string { + for (const base of baseUrls) { + if (base.length > 0 && endpoint.startsWith(base)) { + const stripped = endpoint.slice(base.length); + return stripped.startsWith("/") ? stripped : `/${stripped}`; + } + } + return endpoint; +} diff --git a/packages/parser-react/src/scan.test.ts b/packages/parser-react/src/scan.test.ts index 59faef6..9ae291f 100644 --- a/packages/parser-react/src/scan.test.ts +++ b/packages/parser-react/src/scan.test.ts @@ -39,10 +39,15 @@ describe("scanReact on the demo app", () => { expect(instanceOf?.to).toBe(inst.definitionId); }); - it("extracts endpoints with methods", () => { - const sources = graph.nodes.filter((n) => n.kind === "data-source"); - const endpoints = sources.map((s) => (s.kind === "data-source" ? `${s.method} ${s.endpoint}` : "")); - expect(endpoints.sort()).toEqual(["DELETE /api/users/${user.id}", "GET /api/users"]); + it("extracts endpoints with methods, resolution, and raw source", () => { + const sources = graph.nodes.flatMap((n) => (n.kind === "data-source" ? [n] : [])); + const endpoints = sources.map((s) => `${s.method} ${s.endpoint} (${s.resolved})`); + expect(endpoints.sort()).toEqual([ + "DELETE /api/users/:id (partial)", + "GET /api/users (full)", + ]); + const del = sources.find((s) => s.method === "DELETE"); + expect(del?.raw).toBe("`/api/users/${user.id}`"); }); it("matches screenshot text to UserList via the envelope", () => { @@ -57,7 +62,7 @@ describe("scanReact on the demo app", () => { const lineage = result.candidates[0]?.value; expect(lineage?.dataSources.map((d) => d.endpoint).sort()).toEqual([ "/api/users", - "/api/users/${user.id}", + "/api/users/:id", ]); expect(lineage?.state.map((s) => s.name).sort()).toEqual(["loading", "users"]); expect(lineage?.events.map((e) => e.event)).toEqual(["onClick"]); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index f72ae36..dd8bf2b 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -24,11 +24,19 @@ import { type VariableDeclaration, } from "ts-morph"; +import { resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; + export interface ScanOptions { /** Directory to scan. */ root: string; /** Glob patterns relative to root. Default: all .tsx/.jsx plus hook-looking .ts files. */ include?: string[]; + /** + * Base-URL prefixes stripped from resolved endpoints, e.g. + * ["https://api.example.com", "/v2"]. Keeps the graph's endpoint patterns + * environment-independent. + */ + baseUrls?: string[]; } type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression; @@ -68,6 +76,7 @@ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", " export function scanReact(options: ScanOptions): LineageGraph { const root = path.resolve(options.root); const include = options.include ?? ["**/*.tsx", "**/*.jsx", "**/*.ts"]; + const baseUrls = options.baseUrls ?? []; const project = new Project({ compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ }, @@ -116,7 +125,7 @@ export function scanReact(options: ScanOptions): LineageGraph { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl, id, file, nodes, addEdge); + extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls); } } @@ -256,11 +265,12 @@ function extractBodyFacts( file: string, nodes: Map, addEdge: (edge: LineageEdge) => void, + baseUrls: string[], ): void { for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) { const callee = call.getExpression().getText(); - const dataSource = detectDataSource(call, callee); + const dataSource = detectDataSource(call, callee, baseUrls); if (dataSource !== null) { const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`); if (!nodes.has(dsId)) { @@ -272,6 +282,8 @@ function extractBodyFacts( sourceKind: dataSource.sourceKind, method: dataSource.method, endpoint: dataSource.endpoint, + raw: dataSource.raw, + resolved: dataSource.resolved, }); } addEdge({ from: ownerId, to: dsId, kind: "fetches-from" }); @@ -328,14 +340,15 @@ function extractBodyFacts( function detectDataSource( call: CallExpression, callee: string, -): { sourceKind: DataSourceKind; method: string | null; endpoint: string } | null { + baseUrls: string[], +): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null { const firstArg = call.getArguments()[0]; if (callee === "fetch") { return { sourceKind: "fetch", method: fetchMethod(call), - endpoint: literalText(firstArg) ?? "", + ...resolveEndpoint(firstArg, baseUrls), }; } @@ -345,17 +358,27 @@ function detectDataSource( return { sourceKind: "axios", method: method !== undefined && HTTP_METHODS.has(method) ? method.toUpperCase() : null, - endpoint: literalText(firstArg) ?? "", + ...resolveEndpoint(firstArg, baseUrls), }; } if (callee === "useQuery" || callee === "useMutation" || callee === "useInfiniteQuery") { - // Endpoint is inside the queryFn; surface the query key as the identity. - return { sourceKind: "react-query", method: null, endpoint: literalText(firstArg) ?? call.getArguments()[0]?.getText().slice(0, 80) ?? "" }; + // Endpoint lives inside the queryFn (followed in step 1.3); the query key + // is the identity meanwhile. + const resolved = resolveEndpoint(firstArg, baseUrls); + return { + sourceKind: "react-query", + method: null, + ...resolved, + endpoint: + resolved.resolved === "none" + ? (firstArg?.getText().slice(0, 80) ?? "") + : resolved.endpoint, + }; } if (callee === "useSWR") { - return { sourceKind: "swr", method: "GET", endpoint: literalText(firstArg) ?? "" }; + return { sourceKind: "swr", method: "GET", ...resolveEndpoint(firstArg, baseUrls) }; } return null; diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index c3ba0c5..8a8a5e1 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -312,7 +312,14 @@ }, "endpoint": { "type": "string", - "description": "The endpoint as written in source β€” may contain template placeholders, e.g. \"/api/users/${id}\"." + "description": "Canonical endpoint pattern: constants folded, template placeholders normalized to :param form β€” e.g. \"/api/users/:id\". This is the value attribution and cross-graph joins match on." + }, + "raw": { + "type": "string", + "description": "The endpoint expression exactly as written in source." + }, + "resolved": { + "$ref": "#/definitions/EndpointResolution" } }, "required": [ @@ -322,6 +329,8 @@ "loc", "method", "name", + "raw", + "resolved", "sourceKind" ], "additionalProperties": false, @@ -339,6 +348,15 @@ "unknown" ] }, + "EndpointResolution": { + "type": "string", + "enum": [ + "full", + "partial", + "none" + ], + "description": "How much of an endpoint was statically resolvable." + }, "StateNode": { "type": "object", "properties": { From 44e439834950a42645eb659bb8c387cb5469d03e Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:22:22 +0530 Subject: [PATCH 07/49] =?UTF-8?q?feat(parser-react):=20API-client=20wrappe?= =?UTF-8?q?r=20adapter=20=E2=80=94=20heuristic=20detection=20+=20config,?= =?UTF-8?q?=20chains=20to=20depth=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.2 (TRACKER). Failure mode C2 (wrapper half): - detectWrappers: any named callable (function, const fn, object-literal method) whose body reaches fetch/axios/another wrapper with one of its own params in the URL is classified as a wrapper. Templates compose through chains (useApi β†’ apiClient.get β†’ request β†’ fetch), so a call site's argument substitutes all the way down with constants folded: useApi('/projects') β†’ /api/projects. - Method inference: name suffix (apiClient.post β†’ POST) beats inner method. - ScanOptions.apiWrappers declares wrappers the heuristic can't see. - Wrapper bodies are plumbing: their own internal fetch emits no data source, so ':path' placeholders never leak into consumer attribution (fixture has forbidden assertions for exactly that poison). - Thresholds ratcheted: minLineageRecall 0.85. Scorecard: 47 pass / 0 fail / 2 xfail; recall 0.857. 39 unit tests (6 new wrapper tests). Co-Authored-By: Claude Fable 5 --- TRACKER.md | 6 +- .../app/CreateProjectButton.tsx | 9 + .../c2-api-wrapper/app/ProjectsPage.tsx | 16 ++ .../fixtures/c2-api-wrapper/app/api/client.ts | 16 ++ .../c2-api-wrapper/app/hooks/useApi.ts | 6 + eval/fixtures/c2-api-wrapper/golden.json | 35 ++++ eval/history.jsonl | 1 + eval/thresholds.json | 4 +- packages/parser-react/src/endpoint.ts | 30 ++- packages/parser-react/src/scan.ts | 61 +++--- packages/parser-react/src/wrappers.test.ts | 81 ++++++++ packages/parser-react/src/wrappers.ts | 189 ++++++++++++++++++ 12 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx create mode 100644 eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx create mode 100644 eval/fixtures/c2-api-wrapper/app/api/client.ts create mode 100644 eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts create mode 100644 eval/fixtures/c2-api-wrapper/golden.json create mode 100644 packages/parser-react/src/wrappers.test.ts create mode 100644 packages/parser-react/src/wrappers.ts diff --git a/TRACKER.md b/TRACKER.md index 0899814..c44f43f 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 1 β€” Robust extraction -- **Next step:** 1.2 β€” API-client wrapper adapter -- **Done:** 0.1–0.4, 1.1 +- **Next step:** 1.3 β€” react-query / SWR queryFn following +- **Done:** 0.1–0.4, 1.1, 1.2 - **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) ## What CodeRadar is @@ -97,7 +97,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. - `DataSourceNode` gains `{ pattern: string, resolved: "full" | "partial" | "none", raw: string }`. **Accept:** fixtures `c2-endpoint-constants`, `c3-dynamic-endpoints` green; lineage precision holds β‰₯ 0.90 on all existing fixtures. -### [ ] 1.2 API-client wrapper adapter +### [x] 1.2 API-client wrapper adapter **Failure modes:** C2 (wrapper half) **Build:** - Detection heuristic: a function/method whose body reaches `fetch`/`axios` and takes a path-like parameter β†’ classified as an API wrapper; its call sites become data sources with the path argument resolved per 1.1. diff --git a/eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx b/eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx new file mode 100644 index 0000000..26312bb --- /dev/null +++ b/eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx @@ -0,0 +1,9 @@ +import { apiClient } from "./api/client"; + +export function CreateProjectButton() { + const handleCreate = () => { + apiClient.post("/projects", { name: "Untitled" }); + }; + + return ; +} diff --git a/eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx b/eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx new file mode 100644 index 0000000..9a8e54f --- /dev/null +++ b/eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx @@ -0,0 +1,16 @@ +import { useApi } from "./hooks/useApi"; + +export function ProjectsPage() { + const projects = useApi("/projects") as unknown as string[]; + + return ( +
+

Projects overview

+
    + {projects.map((p) => ( +
  • {p}
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/c2-api-wrapper/app/api/client.ts b/eval/fixtures/c2-api-wrapper/app/api/client.ts new file mode 100644 index 0000000..242ac1d --- /dev/null +++ b/eval/fixtures/c2-api-wrapper/app/api/client.ts @@ -0,0 +1,16 @@ +const API_BASE = "/api"; + +async function request(path: string, init?: RequestInit) { + const res = await fetch(`${API_BASE}${path}`, init); + if (!res.ok) throw new Error(`request failed: ${res.status}`); + return res.json(); +} + +export const apiClient = { + get(path: string) { + return request(path); + }, + post(path: string, body: unknown) { + return request(path, { method: "POST", body: JSON.stringify(body) }); + }, +}; diff --git a/eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts b/eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts new file mode 100644 index 0000000..84934e3 --- /dev/null +++ b/eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts @@ -0,0 +1,6 @@ +import { apiClient } from "../api/client"; + +/** Thin data hook β€” the third wrapper layer: useApi β†’ apiClient.get β†’ request β†’ fetch. */ +export function useApi(path: string) { + return apiClient.get(path); +} diff --git a/eval/fixtures/c2-api-wrapper/golden.json b/eval/fixtures/c2-api-wrapper/golden.json new file mode 100644 index 0000000..7bfe953 --- /dev/null +++ b/eval/fixtures/c2-api-wrapper/golden.json @@ -0,0 +1,35 @@ +{ + "failureMode": "C2", + "note": "Three wrapper layers: useApi -> apiClient.get -> request -> fetch(`${API_BASE}${path}`). Call sites must resolve through the whole chain with the base folded in: useApi('/projects') β†’ /api/projects. The wrapper bodies themselves must NOT emit placeholder (:path) data sources.", + "expect": { + "components": [ + { "name": "ProjectsPage", "instances": 0 }, + { "name": "CreateProjectButton", "instances": 0 } + ], + "attributions": [ + { "component": "ProjectsPage", "endpoints": ["/api/projects"] }, + { "component": "CreateProjectButton", "endpoints": ["/api/projects"] } + ], + "forbidden": [ + { + "component": "ProjectsPage", + "endpoint": ":path", + "note": "poison: wrapper-internal placeholder leaked to a consumer" + }, + { + "component": "ProjectsPage", + "endpoint": "/api:path", + "note": "poison: partially-composed wrapper template leaked to a consumer" + }, + { + "component": "ProjectsPage", + "endpoint": "", + "note": "wrapper call collapsed to dynamic means the adapter regressed" + } + ], + "queries": [ + { "terms": ["Projects overview"], "status": "ok", "top": "ProjectsPage" }, + { "terms": ["New project"], "status": "ok", "top": "CreateProjectButton" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index 0840169..2510e41 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -1,2 +1,3 @@ {"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1} +{"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1} diff --git a/eval/thresholds.json b/eval/thresholds.json index 01e8e4c..a34d44b 100644 --- a/eval/thresholds.json +++ b/eval/thresholds.json @@ -1,7 +1,7 @@ { "maxFail": 0, "maxUnexpectedPass": 0, - "minMatchAccuracy": 1.0, + "minMatchAccuracy": 1, "minLineagePrecision": 0.9, - "minLineageRecall": 0.8 + "minLineageRecall": 0.85 } diff --git a/packages/parser-react/src/endpoint.ts b/packages/parser-react/src/endpoint.ts index 314cc0a..1b860fc 100644 --- a/packages/parser-react/src/endpoint.ts +++ b/packages/parser-react/src/endpoint.ts @@ -12,7 +12,7 @@ */ import type { EndpointResolution } from "@coderadar/core"; -import { Node, SyntaxKind } from "ts-morph"; +import { type CallExpression, Node, SyntaxKind } from "ts-morph"; export interface ResolvedEndpoint { endpoint: string; @@ -155,6 +155,34 @@ function resolveObjectLiteral(node: Node | undefined, depth: number): import("ts return null; } +/** + * Resolve a URL expression inside a wrapper body, where the wrapper's own + * parameters become :param placeholders. `request(path)` with body + * `fetch(\`${API_BASE}${path}\`)` yields "/api:path". Null when the + * expression has no statically-known shape at all. + */ +export function resolveUrlTemplate(node: Node, paramNames: ReadonlySet): string | null { + if (Node.isIdentifier(node) && paramNames.has(node.getText())) { + return `:${node.getText()}`; + } + const full = resolveStringValue(node, 0); + if (full !== null) return full; + return resolvePattern(node); +} + +/** The statically-visible HTTP method of a fetch(url, { method: ... }) call. */ +export function fetchMethod(call: CallExpression): string { + const optionsArg = call.getArguments()[1]; + if (optionsArg !== undefined && Node.isObjectLiteralExpression(optionsArg)) { + const methodProp = optionsArg.getProperty("method"); + if (methodProp !== undefined && Node.isPropertyAssignment(methodProp)) { + const value = resolveStringValue(methodProp.getInitializer(), 0); + if (value !== null) return value.toUpperCase(); + } + } + return "GET"; +} + function stripBaseUrls(endpoint: string, baseUrls: string[]): string { for (const base of baseUrls) { if (base.length > 0 && endpoint.startsWith(base)) { diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index dd8bf2b..852c258 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -24,7 +24,8 @@ import { type VariableDeclaration, } from "ts-morph"; -import { resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; +import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; +import { detectWrappers, type WrapperRegistry } from "./wrappers.js"; export interface ScanOptions { /** Directory to scan. */ @@ -37,6 +38,11 @@ export interface ScanOptions { * environment-independent. */ baseUrls?: string[]; + /** + * Explicitly-declared API wrapper callees (e.g. ["http.get", "api.post"]) + * for clients the heuristic can't see. Heuristic detection runs regardless. + */ + apiWrappers?: string[]; } type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression; @@ -90,6 +96,7 @@ export function scanReact(options: ScanOptions): LineageGraph { ]); } + const wrappers = detectWrappers(project, options.apiWrappers ?? []); const nodes = new Map(); const edges: LineageEdge[] = []; const pendingInstances: PendingInstance[] = []; @@ -125,7 +132,7 @@ export function scanReact(options: ScanOptions): LineageGraph { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls); + extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls, wrappers); } } @@ -266,11 +273,17 @@ function extractBodyFacts( nodes: Map, addEdge: (edge: LineageEdge) => void, baseUrls: string[], + wrappers: WrapperRegistry, ): void { + // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a + // data source emitted here would attribute ":path" to every consumer. Call + // sites get the real, substituted endpoint instead. + const declIsWrapper = wrappers.has(decl.name); + for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) { const callee = call.getExpression().getText(); - const dataSource = detectDataSource(call, callee, baseUrls); + const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers); if (dataSource !== null) { const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`); if (!nodes.has(dsId)) { @@ -341,9 +354,26 @@ function detectDataSource( call: CallExpression, callee: string, baseUrls: string[], + wrappers: WrapperRegistry, ): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null { const firstArg = call.getArguments()[0]; + const wrapper = wrappers.get(callee); + if (wrapper !== undefined) { + const pathArg = call.getArguments()[wrapper.pathParamIndex]; + const resolved = resolveEndpoint(pathArg, baseUrls); + const substitution = + resolved.resolved === "none" ? `:${wrapper.paramName}` : resolved.endpoint; + const endpoint = wrapper.template.replace(`:${wrapper.paramName}`, substitution); + return { + sourceKind: wrapper.sourceKind, + method: wrapper.method, + endpoint, + raw: call.getText().slice(0, 120), + resolved: endpoint.includes(":") ? "partial" : "full", + }; + } + if (callee === "fetch") { return { sourceKind: "fetch", @@ -384,31 +414,6 @@ function detectDataSource( return null; } -function fetchMethod(call: CallExpression): string { - const optionsArg = call.getArguments()[1]; - if (optionsArg !== undefined && Node.isObjectLiteralExpression(optionsArg)) { - const methodProp = optionsArg.getProperty("method"); - if (methodProp !== undefined && Node.isPropertyAssignment(methodProp)) { - const value = methodProp.getInitializer(); - const literal = literalText(value); - if (literal !== null) return literal.toUpperCase(); - } - } - return "GET"; -} - -/** String literal or template text (with `${...}` placeholders preserved). */ -function literalText(node: Node | undefined): string | null { - if (node === undefined) return null; - if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { - return node.getLiteralValue(); - } - if (Node.isTemplateExpression(node)) { - return node.getText().slice(1, -1); // keep ${...} placeholders, drop backticks - } - return null; -} - function detectState(callee: string): "useState" | "useReducer" | "context" | "redux" | "zustand" | null { switch (callee) { case "useState": diff --git a/packages/parser-react/src/wrappers.test.ts b/packages/parser-react/src/wrappers.test.ts new file mode 100644 index 0000000..642a646 --- /dev/null +++ b/packages/parser-react/src/wrappers.test.ts @@ -0,0 +1,81 @@ +import { Project } from "ts-morph"; +import { describe, expect, it } from "vitest"; + +import { detectWrappers } from "./wrappers.js"; + +function projectWith(code: string): Project { + const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } }); + project.createSourceFile("client.ts", code); + return project; +} + +describe("detectWrappers", () => { + it("detects a plain function wrapper over fetch", () => { + const registry = detectWrappers( + projectWith(`function apiGet(path: string) { return fetch(path); }`), + [], + ); + expect(registry.get("apiGet")).toMatchObject({ + paramName: "path", + pathParamIndex: 0, + template: ":path", + method: "GET", + sourceKind: "fetch", + }); + }); + + it("captures a URL prefix from the wrapper body", () => { + const registry = detectWrappers( + projectWith( + `const BASE = "/api"; + function request(path: string) { return fetch(\`\${BASE}\${path}\`); }`, + ), + [], + ); + expect(registry.get("request")?.template).toBe("/api:path"); + }); + + it("detects object-literal methods and infers method from the name suffix", () => { + const registry = detectWrappers( + projectWith( + `function request(path: string, init?: RequestInit) { return fetch(path, init); } + const apiClient = { + get(path: string) { return request(path); }, + post(path: string, body: unknown) { return request(path, { method: "POST" }); }, + };`, + ), + [], + ); + expect(registry.get("apiClient.get")?.method).toBe("GET"); + expect(registry.get("apiClient.post")?.method).toBe("POST"); + }); + + it("composes templates through a three-layer chain", () => { + const registry = detectWrappers( + projectWith( + `const BASE = "/api"; + function request(path: string) { return fetch(\`\${BASE}\${path}\`); } + const apiClient = { get(p: string) { return request(p); } }; + function useApi(route: string) { return apiClient.get(route); }`, + ), + [], + ); + expect(registry.get("request")?.template).toBe("/api:path"); + expect(registry.get("apiClient.get")?.template).toBe("/api:p"); + expect(registry.get("useApi")?.template).toBe("/api:route"); + }); + + it("registers configured wrappers without needing their source", () => { + const registry = detectWrappers(projectWith(`export {};`), ["http.post", "sdk.request"]); + expect(registry.get("http.post")).toMatchObject({ template: ":path", method: "POST" }); + expect(registry.get("sdk.request")).toMatchObject({ template: ":path", method: null }); + }); + + it("ignores functions that never reach a data source", () => { + const registry = detectWrappers( + projectWith(`function formatPath(path: string) { return path.trim(); }`), + [], + ); + expect(registry.has("formatPath")).toBe(false); + }); +}); diff --git a/packages/parser-react/src/wrappers.ts b/packages/parser-react/src/wrappers.ts new file mode 100644 index 0000000..40d6aac --- /dev/null +++ b/packages/parser-react/src/wrappers.ts @@ -0,0 +1,189 @@ +/** + * API-client wrapper detection (TRACKER step 1.2, failure mode C2). + * + * Real codebases route every request through wrapper layers: + * + * useApi("/projects") β†’ apiClient.get(path) β†’ request(path) β†’ fetch(`${API_BASE}${path}`) + * + * A wrapper is any named callable whose body reaches fetch/axios (or another + * wrapper) with one of its own parameters inside the URL. Detection composes + * templates through the chain, so a call site's argument substitutes all the + * way down: apiClient.get("/projects") β†’ "/api/projects". + * + * Wrappers can also be declared explicitly via ScanOptions.apiWrappers + * (e.g. ["http.get", "api.post"]) for clients the heuristic can't see + * (imported from node_modules, class-based, etc.). + */ + +import type { DataSourceKind } from "@coderadar/core"; +import { Node, type Project, SyntaxKind } from "ts-morph"; + +import { fetchMethod, resolveUrlTemplate } from "./endpoint.js"; + +export interface WrapperInfo { + /** How call sites reference it: "request", "apiClient.get", "useApi". */ + callee: string; + /** Name and position of the path parameter. */ + paramName: string; + pathParamIndex: number; + /** + * Endpoint pattern with ":" marking where the call-site argument + * lands, e.g. "/api:path". Composed through wrapper chains. + */ + template: string; + method: string | null; + sourceKind: DataSourceKind; +} + +export type WrapperRegistry = ReadonlyMap; + +/** How many wrapper layers detection follows (useApi β†’ apiClient.get β†’ request). */ +const MAX_CHAIN_ROUNDS = 3; + +const METHOD_SUFFIX = /(get|post|put|patch|delete|head|options)$/i; + +interface Callable { + callee: string; + body: Node; + paramNames: string[]; +} + +export function detectWrappers(project: Project, configured: string[]): WrapperRegistry { + const registry = new Map(); + + for (const callee of configured) { + registry.set(callee, { + callee, + paramName: "path", + pathParamIndex: 0, + template: ":path", + method: suffixMethod(callee), + sourceKind: "unknown", + }); + } + + const callables = collectCallables(project); + for (let round = 0; round < MAX_CHAIN_ROUNDS; round += 1) { + let changed = false; + for (const callable of callables) { + if (registry.has(callable.callee)) continue; + const info = classifyWrapper(callable, registry); + if (info !== null) { + registry.set(callable.callee, info); + changed = true; + } + } + if (!changed) break; + } + return registry; +} + +/** Every named callable a call site could reference: functions, const fns, object methods. */ +function collectCallables(project: Project): Callable[] { + const callables: Callable[] = []; + for (const sourceFile of project.getSourceFiles()) { + for (const fn of sourceFile.getFunctions()) { + const name = fn.getName(); + const body = fn.getBody(); + if (name === undefined || body === undefined) continue; + callables.push({ callee: name, body, paramNames: fn.getParameters().map((p) => p.getName()) }); + } + for (const variable of sourceFile.getVariableDeclarations()) { + const init = variable.getInitializer(); + if (init === undefined) continue; + if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) { + callables.push({ + callee: variable.getName(), + body: init.getBody() ?? init, + paramNames: init.getParameters().map((p) => p.getName()), + }); + } else if (Node.isObjectLiteralExpression(init)) { + for (const property of init.getProperties()) { + if (Node.isMethodDeclaration(property)) { + const body = property.getBody(); + if (body === undefined) continue; + callables.push({ + callee: `${variable.getName()}.${property.getName()}`, + body, + paramNames: property.getParameters().map((p) => p.getName()), + }); + } else if (Node.isPropertyAssignment(property)) { + const value = property.getInitializer(); + if (value !== undefined && (Node.isArrowFunction(value) || Node.isFunctionExpression(value))) { + callables.push({ + callee: `${variable.getName()}.${property.getName()}`, + body: value.getBody() ?? value, + paramNames: value.getParameters().map((p) => p.getName()), + }); + } + } + } + } + } + } + return callables; +} + +function classifyWrapper(callable: Callable, registry: WrapperRegistry): WrapperInfo | null { + if (callable.paramNames.length === 0) return null; + const paramSet = new Set(callable.paramNames); + + for (const call of callable.body.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression().getText(); + + let urlIndex: number; + let innerTemplate: string | null = null; + let innerParam: string | null = null; + let innerMethod: string | null; + let sourceKind: DataSourceKind; + + const axiosMatch = /^axios(?:\.(\w+))?$/.exec(callee); + const inner = registry.get(callee); + if (callee === "fetch") { + urlIndex = 0; + innerMethod = fetchMethod(call); + sourceKind = "fetch"; + } else if (axiosMatch !== null) { + urlIndex = 0; + innerMethod = axiosMatch[1] !== undefined ? axiosMatch[1].toUpperCase() : null; + sourceKind = "axios"; + } else if (inner !== undefined) { + urlIndex = inner.pathParamIndex; + innerTemplate = inner.template; + innerParam = inner.paramName; + innerMethod = inner.method; + sourceKind = inner.sourceKind; + } else { + continue; + } + + const urlArg = call.getArguments()[urlIndex]; + if (urlArg === undefined) continue; + const pattern = resolveUrlTemplate(urlArg, paramSet); + if (pattern === null) continue; + const usedParam = callable.paramNames.find((p) => pattern.includes(`:${p}`)); + if (usedParam === undefined) continue; + + const template = + innerTemplate !== null && innerParam !== null + ? innerTemplate.replace(`:${innerParam}`, pattern) + : pattern; + + return { + callee: callable.callee, + paramName: usedParam, + pathParamIndex: callable.paramNames.indexOf(usedParam), + template, + method: suffixMethod(callable.callee) ?? innerMethod, + sourceKind, + }; + } + return null; +} + +/** "apiClient.post" β†’ "POST"; "request" β†’ null. */ +function suffixMethod(callee: string): string | null { + const lastSegment = callee.split(".").pop() ?? callee; + const match = METHOD_SUFFIX.exec(lastSegment); + return match?.[1] !== undefined ? match[1].toUpperCase() : null; +} From 40b2505fc1811e5b0531644d049229fab8c0c89f Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:26:46 +0530 Subject: [PATCH 08/49] feat(parser-react): follow react-query/SWR queryFn references to their endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.3 (TRACKER). Failure mode C5: - endpointFromFunction: queryFn/mutationFn/fetcher expressions resolve to a function body β€” inline arrows directly, references via go-to-definition (cross-file) β€” and the first data source inside is extracted with the full 1.1/1.2 machinery (constant folding + wrapper substitution work inside queryFns for free). - Covers v4/v5 object form ({queryKey, queryFn} / {mutationFn}), v3 positional forms (useQuery(key, fn) / useMutation(fn)), and SWR key-or- fetcher fallback. Mutations pick up the inner call's method (POST). - DataSourceNode gains queryKey β€” the cache identity recorded alongside the endpoint, groundwork for cache-sharing analysis (C7). - Fixture c5-queryfn-indirection with the poison assertion that a query KEY must never be reported as an endpoint. - Thresholds ratcheted: minLineageRecall 0.88. Scorecard: 60 pass / 0 fail / 2 xfail; recall 0.889. 44 unit tests. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 6 +- .../app/InviteButton.tsx | 11 +++ .../c5-queryfn-indirection/app/StatsCard.tsx | 15 +++ .../c5-queryfn-indirection/app/TeamPanel.tsx | 15 +++ .../app/UsersDashboard.tsx | 14 +++ .../c5-queryfn-indirection/app/api/users.ts | 17 ++++ .../c5-queryfn-indirection/golden.json | 35 +++++++ eval/history.jsonl | 1 + eval/thresholds.json | 2 +- packages/core/src/types.ts | 5 + packages/parser-react/src/queryfn.test.ts | 43 ++++++++ packages/parser-react/src/scan.ts | 99 ++++++++++++++++++- schemas/lineage-graph.schema.json | 4 + 13 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx create mode 100644 eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx create mode 100644 eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx create mode 100644 eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx create mode 100644 eval/fixtures/c5-queryfn-indirection/app/api/users.ts create mode 100644 eval/fixtures/c5-queryfn-indirection/golden.json create mode 100644 packages/parser-react/src/queryfn.test.ts diff --git a/TRACKER.md b/TRACKER.md index c44f43f..3902af5 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 1 β€” Robust extraction -- **Next step:** 1.3 β€” react-query / SWR queryFn following -- **Done:** 0.1–0.4, 1.1, 1.2 +- **Next step:** 1.4 β€” i18n adapter +- **Done:** 0.1–0.4, 1.1–1.3 - **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) ## What CodeRadar is @@ -105,7 +105,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. - Wrapper chains up to depth 3 (`useApi β†’ apiClient.get β†’ fetch`). **Accept:** fixture `c2-api-wrapper` (three-layer wrapper) green; wrapper detection has unit tests for both heuristic and config paths. -### [ ] 1.3 react-query / SWR queryFn following +### [x] 1.3 react-query / SWR queryFn following **Failure modes:** C5 **Build:** when `useQuery`/`useMutation`/`useSWR`'s fn argument is a reference, resolve to its declaration (same file or import) and extract the endpoint from its body via 1.1/1.2. Data source records both the query key and the resolved endpoint; mutations get `method` from the inner call. **Accept:** fixture `c5-queryfn-indirection` green (queryFn in a separate `api/users.ts`). diff --git a/eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx b/eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx new file mode 100644 index 0000000..fd41880 --- /dev/null +++ b/eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; + +import { createUser } from "./api/users"; + +export function InviteButton() { + const mutation = useMutation({ mutationFn: createUser }); + + return ( + + ); +} diff --git a/eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx b/eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx new file mode 100644 index 0000000..3c7d5b7 --- /dev/null +++ b/eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +export function StatsCard() { + const { data } = useQuery({ + queryKey: ["stats"], + queryFn: () => fetch("/api/stats").then((r) => r.json()), + }); + + return ( +
+

Weekly stats

+ {data?.total} +
+ ); +} diff --git a/eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx b/eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx new file mode 100644 index 0000000..7d020de --- /dev/null +++ b/eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx @@ -0,0 +1,15 @@ +import { useQuery } from "react-query"; + +import { fetchTeam } from "./api/users"; + +/** Legacy v3 positional form. */ +export function TeamPanel() { + const { data } = useQuery(["team"], fetchTeam); + + return ( + + ); +} diff --git a/eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx b/eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx new file mode 100644 index 0000000..6c81c58 --- /dev/null +++ b/eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchUsers } from "./api/users"; + +export function UsersDashboard() { + const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers }); + + return ( +
+

Active users

+

{data?.length ?? 0} online

+
+ ); +} diff --git a/eval/fixtures/c5-queryfn-indirection/app/api/users.ts b/eval/fixtures/c5-queryfn-indirection/app/api/users.ts new file mode 100644 index 0000000..6e8c723 --- /dev/null +++ b/eval/fixtures/c5-queryfn-indirection/app/api/users.ts @@ -0,0 +1,17 @@ +export async function fetchUsers() { + const res = await fetch("/api/users"); + return res.json(); +} + +export async function createUser(body: { name: string }) { + const res = await fetch("/api/users", { + method: "POST", + body: JSON.stringify(body), + }); + return res.json(); +} + +export const fetchTeam = async () => { + const res = await fetch("/api/team"); + return res.json(); +}; diff --git a/eval/fixtures/c5-queryfn-indirection/golden.json b/eval/fixtures/c5-queryfn-indirection/golden.json new file mode 100644 index 0000000..2a6d136 --- /dev/null +++ b/eval/fixtures/c5-queryfn-indirection/golden.json @@ -0,0 +1,35 @@ +{ + "failureMode": "C5", + "note": "react-query queryFn indirection: the hook call site contains no URL β€” the endpoint lives inside fetchUsers/createUser/fetchTeam declared in api/users.ts. Covers v4/v5 object form (queryFn/mutationFn), v3 positional form, and an inline arrow queryFn.", + "expect": { + "components": [ + { "name": "UsersDashboard", "instances": 0 }, + { "name": "InviteButton", "instances": 0 }, + { "name": "TeamPanel", "instances": 0 }, + { "name": "StatsCard", "instances": 0 } + ], + "attributions": [ + { "component": "UsersDashboard", "endpoints": ["/api/users"] }, + { "component": "InviteButton", "endpoints": ["/api/users"] }, + { "component": "TeamPanel", "endpoints": ["/api/team"] }, + { "component": "StatsCard", "endpoints": ["/api/stats"] } + ], + "forbidden": [ + { + "component": "UsersDashboard", + "endpoint": "", + "note": "queryFn reference not followed β€” endpoint collapsed to dynamic" + }, + { + "component": "TeamPanel", + "endpoint": "[\"team\"]", + "note": "poison: the query KEY reported as if it were the endpoint" + } + ], + "queries": [ + { "terms": ["Active users"], "status": "ok", "top": "UsersDashboard" }, + { "terms": ["Invite user"], "status": "ok", "top": "InviteButton" }, + { "terms": ["Weekly stats"], "status": "ok", "top": "StatsCard" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index 2510e41..baf2ea4 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -1,3 +1,4 @@ {"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1} +{"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} diff --git a/eval/thresholds.json b/eval/thresholds.json index a34d44b..b7ecceb 100644 --- a/eval/thresholds.json +++ b/eval/thresholds.json @@ -3,5 +3,5 @@ "maxUnexpectedPass": 0, "minMatchAccuracy": 1, "minLineagePrecision": 0.9, - "minLineageRecall": 0.85 + "minLineageRecall": 0.88 } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index af0ebff..8d6b6ec 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -116,6 +116,11 @@ export interface DataSourceNode extends BaseNode { /** The endpoint expression exactly as written in source. */ raw: string; resolved: EndpointResolution; + /** + * react-query/SWR cache key as written in source (e.g. `["users"]`) β€” + * the identity used for cache-sharing analysis (C7) alongside the endpoint. + */ + queryKey?: string; } export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown"; diff --git a/packages/parser-react/src/queryfn.test.ts b/packages/parser-react/src/queryfn.test.ts new file mode 100644 index 0000000..377d010 --- /dev/null +++ b/packages/parser-react/src/queryfn.test.ts @@ -0,0 +1,43 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { scanReact } from "./scan.js"; + +const fixture = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/c5-queryfn-indirection/app", +); + +const graph = scanReact({ root: fixture }); +const sources = graph.nodes.flatMap((n) => (n.kind === "data-source" ? [n] : [])); + +describe("queryFn following (c5 fixture)", () => { + it("resolves v4/v5 object-form queryFn references to their endpoint", () => { + const users = sources.find((s) => s.endpoint === "/api/users" && s.method === "GET"); + expect(users?.sourceKind).toBe("react-query"); + expect(users?.queryKey).toBe(`["users"]`); + }); + + it("resolves mutationFn and picks up the inner POST method", () => { + const post = sources.find((s) => s.endpoint === "/api/users" && s.method === "POST"); + expect(post?.sourceKind).toBe("react-query"); + }); + + it("resolves the v3 positional form with an arrow-const fn", () => { + const team = sources.find((s) => s.endpoint === "/api/team"); + expect(team?.sourceKind).toBe("react-query"); + expect(team?.queryKey).toBe(`["team"]`); + }); + + it("resolves inline arrow queryFns", () => { + const stats = sources.find((s) => s.endpoint === "/api/stats"); + expect(stats?.sourceKind).toBe("react-query"); + }); + + it("never reports a query key as the endpoint", () => { + expect(sources.some((s) => s.endpoint.startsWith("["))).toBe(false); + expect(sources.some((s) => s.endpoint === "")).toBe(false); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 852c258..d085719 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -297,6 +297,7 @@ function extractBodyFacts( endpoint: dataSource.endpoint, raw: dataSource.raw, resolved: dataSource.resolved, + ...(dataSource.queryKey !== undefined ? { queryKey: dataSource.queryKey } : {}), }); } addEdge({ from: ownerId, to: dsId, kind: "fetches-from" }); @@ -350,12 +351,18 @@ function extractBodyFacts( } } +type DetectedSource = { + sourceKind: DataSourceKind; + method: string | null; + queryKey?: string; +} & ResolvedEndpoint; + function detectDataSource( call: CallExpression, callee: string, baseUrls: string[], wrappers: WrapperRegistry, -): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null { +): DetectedSource | null { const firstArg = call.getArguments()[0]; const wrapper = wrappers.get(callee); @@ -393,8 +400,26 @@ function detectDataSource( } if (callee === "useQuery" || callee === "useMutation" || callee === "useInfiniteQuery") { - // Endpoint lives inside the queryFn (followed in step 1.3); the query key - // is the identity meanwhile. + let keyText: string | undefined; + let fnExpr: Node | undefined; + if (firstArg !== undefined && Node.isObjectLiteralExpression(firstArg)) { + // v4/v5 object form: useQuery({ queryKey, queryFn }) / useMutation({ mutationFn }) + keyText = propertyInitializer(firstArg, "queryKey")?.getText() ?? + propertyInitializer(firstArg, "mutationKey")?.getText(); + fnExpr = propertyInitializer(firstArg, "queryFn") ?? propertyInitializer(firstArg, "mutationFn"); + } else if (callee === "useMutation") { + // v3 positional form: useMutation(mutationFn, options?) + fnExpr = firstArg; + } else { + // v3 positional form: useQuery(key, queryFn, options?) + keyText = firstArg?.getText(); + fnExpr = call.getArguments()[1]; + } + const queryKey = keyText?.slice(0, 80); + const inner = fnExpr !== undefined ? endpointFromFunction(fnExpr, baseUrls, wrappers) : null; + if (inner !== null) { + return { ...inner, sourceKind: "react-query", queryKey }; + } const resolved = resolveEndpoint(firstArg, baseUrls); return { sourceKind: "react-query", @@ -404,13 +429,79 @@ function detectDataSource( resolved.resolved === "none" ? (firstArg?.getText().slice(0, 80) ?? "") : resolved.endpoint, + queryKey, }; } if (callee === "useSWR") { - return { sourceKind: "swr", method: "GET", ...resolveEndpoint(firstArg, baseUrls) }; + // SWR convention: the key IS the URL (passed to the fetcher). When the key + // carries no shape, fall back to the fetcher body. + const key = resolveEndpoint(firstArg, baseUrls); + if (key.resolved !== "none") { + return { sourceKind: "swr", method: "GET", ...key, queryKey: firstArg?.getText().slice(0, 80) }; + } + const fetcher = call.getArguments()[1]; + const inner = fetcher !== undefined ? endpointFromFunction(fetcher, baseUrls, wrappers) : null; + if (inner !== null) return { ...inner, sourceKind: "swr" }; + return { sourceKind: "swr", method: "GET", ...key }; + } + + return null; +} + +function propertyInitializer(objectLiteral: Node, name: string): Node | undefined { + if (!Node.isObjectLiteralExpression(objectLiteral)) return undefined; + const property = objectLiteral.getProperty(name); + if (property !== undefined && Node.isPropertyAssignment(property)) { + return property.getInitializer(); + } + if (property !== undefined && Node.isShorthandPropertyAssignment(property)) { + return property.getNameNode(); } + return undefined; +} +/** + * Follow a queryFn/mutationFn/fetcher expression to a function body (inline + * arrow, or a reference resolved via go-to-definition, possibly in another + * file) and extract the first data source inside it. react-query/swr results + * are skipped to avoid self-recursion. + */ +function endpointFromFunction( + expr: Node, + baseUrls: string[], + wrappers: WrapperRegistry, +): DetectedSource | null { + const body = functionBodyOf(expr); + if (body === null) return null; + for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const source = detectDataSource(call, call.getExpression().getText(), baseUrls, wrappers); + if (source !== null && source.sourceKind !== "react-query" && source.sourceKind !== "swr") { + return source; + } + } + return null; +} + +function functionBodyOf(expr: Node): Node | null { + if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) { + return expr.getBody() ?? null; + } + if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) { + const identifier = Node.isPropertyAccessExpression(expr) ? expr.getNameNode() : expr; + if (!Node.isIdentifier(identifier)) return null; + for (const definition of identifier.getDefinitionNodes()) { + if (Node.isFunctionDeclaration(definition)) { + return definition.getBody() ?? null; + } + if (Node.isVariableDeclaration(definition)) { + const init = definition.getInitializer(); + if (init !== undefined && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) { + return init.getBody() ?? null; + } + } + } + } return null; } diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 8a8a5e1..69f5480 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -320,6 +320,10 @@ }, "resolved": { "$ref": "#/definitions/EndpointResolution" + }, + "queryKey": { + "type": "string", + "description": "react-query/SWR cache key as written in source (e.g. `[\"users\"]`) β€” the identity used for cache-sharing analysis (C7) alongside the endpoint." } }, "required": [ From cbf28f97c30886223960ccbe70f819ad8b58dd16 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:32:55 +0530 Subject: [PATCH 09/49] =?UTF-8?q?feat(parser-react):=20i18n=20adapter=20?= =?UTF-8?q?=E2=80=94=20locale-file=20resolution=20for=20t()/Trans=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.4 (TRACKER). Failure mode A2: - renderedText is now structured: { text, source: jsx|attribute|i18n, key?, locale?, branch? } β€” provenance travels into match evidence ('matched "Membres de l'Γ©quipe" (i18n key team.title, locale fr)'). - loadLocaleTable: JSON/YAML locale files via ScanOptions.i18n { localeGlobs, defaultLocale }; nested keys flattened; locale inferred from filename or parent dir; dependency-free mini-glob. - t('key') / anything.t('key') / expand to one entry per locale, so a screenshot in ANY language matches the same component; i18next namespace: prefixes fall back to the bare key. - Eval runner: golden.json gains a scan field (per-fixture scan options). - Fixture a2-i18n-keys (en + fr, t() and Trans): all 5 locale queries green. - Scorecard: 67 pass / 0 fail / 2 xfail. 49 unit tests. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 6 +- .../a2-i18n-keys/app/BillingHeader.tsx | 11 ++ eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx | 12 ++ .../fixtures/a2-i18n-keys/app/locales/en.json | 9 ++ .../fixtures/a2-i18n-keys/app/locales/fr.json | 9 ++ eval/fixtures/a2-i18n-keys/golden.json | 20 +++ eval/history.jsonl | 1 + eval/src/golden.ts | 6 + eval/src/run.ts | 2 +- packages/core/src/query.test.ts | 2 +- packages/core/src/query.ts | 12 +- packages/core/src/types.ts | 25 ++- packages/parser-react/package.json | 3 +- packages/parser-react/src/i18n.test.ts | 63 ++++++++ packages/parser-react/src/i18n.ts | 146 ++++++++++++++++++ packages/parser-react/src/scan.ts | 24 ++- pnpm-lock.yaml | 33 ++-- schemas/lineage-graph.schema.json | 39 ++++- 18 files changed, 390 insertions(+), 33 deletions(-) create mode 100644 eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx create mode 100644 eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx create mode 100644 eval/fixtures/a2-i18n-keys/app/locales/en.json create mode 100644 eval/fixtures/a2-i18n-keys/app/locales/fr.json create mode 100644 eval/fixtures/a2-i18n-keys/golden.json create mode 100644 packages/parser-react/src/i18n.test.ts create mode 100644 packages/parser-react/src/i18n.ts diff --git a/TRACKER.md b/TRACKER.md index 3902af5..70c4d96 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 1 β€” Robust extraction -- **Next step:** 1.4 β€” i18n adapter -- **Done:** 0.1–0.4, 1.1–1.3 +- **Next step:** 1.5 β€” Rendered-text hardening +- **Done:** 0.1–0.4, 1.1–1.4 - **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) ## What CodeRadar is @@ -110,7 +110,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. **Build:** when `useQuery`/`useMutation`/`useSWR`'s fn argument is a reference, resolve to its declaration (same file or import) and extract the endpoint from its body via 1.1/1.2. Data source records both the query key and the resolved endpoint; mutations get `method` from the inner call. **Accept:** fixture `c5-queryfn-indirection` green (queryFn in a separate `api/users.ts`). -### [ ] 1.4 i18n adapter +### [x] 1.4 i18n adapter **Failure modes:** A2 **Build:** - Scan option `i18n: { localeGlobs: string[], defaultLocale: string }`; parse JSON/YAML locale files into a key β†’ string-per-locale table (nested keys flattened, `{{var}}` placeholders preserved). diff --git a/eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx b/eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx new file mode 100644 index 0000000..f1b8a39 --- /dev/null +++ b/eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx @@ -0,0 +1,11 @@ +import { Trans } from "react-i18next"; + +export function BillingHeader() { + return ( +
+

+ +

+
+ ); +} diff --git a/eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx b/eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx new file mode 100644 index 0000000..9926a09 --- /dev/null +++ b/eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx @@ -0,0 +1,12 @@ +import { useTranslation } from "react-i18next"; + +export function TeamHeader() { + const { t } = useTranslation(); + + return ( +
+

{t("team.title")}

+ +
+ ); +} diff --git a/eval/fixtures/a2-i18n-keys/app/locales/en.json b/eval/fixtures/a2-i18n-keys/app/locales/en.json new file mode 100644 index 0000000..0364b78 --- /dev/null +++ b/eval/fixtures/a2-i18n-keys/app/locales/en.json @@ -0,0 +1,9 @@ +{ + "team": { + "title": "Team Members", + "invite": "Invite teammate" + }, + "billing": { + "title": "Billing overview" + } +} diff --git a/eval/fixtures/a2-i18n-keys/app/locales/fr.json b/eval/fixtures/a2-i18n-keys/app/locales/fr.json new file mode 100644 index 0000000..e1ca417 --- /dev/null +++ b/eval/fixtures/a2-i18n-keys/app/locales/fr.json @@ -0,0 +1,9 @@ +{ + "team": { + "title": "Membres de l'Γ©quipe", + "invite": "Inviter un coΓ©quipier" + }, + "billing": { + "title": "AperΓ§u de la facturation" + } +} diff --git a/eval/fixtures/a2-i18n-keys/golden.json b/eval/fixtures/a2-i18n-keys/golden.json new file mode 100644 index 0000000..a72fd85 --- /dev/null +++ b/eval/fixtures/a2-i18n-keys/golden.json @@ -0,0 +1,20 @@ +{ + "failureMode": "A2", + "note": "Text behind i18n keys: source has t('team.title'), the screenshot shows 'Team Members' β€” or 'Membres de l'Γ©quipe' when the reporter's app runs in French. Both must find the same component. Covers t() calls and .", + "scan": { + "i18n": { "localeGlobs": ["locales/*.json"], "defaultLocale": "en" } + }, + "expect": { + "components": [ + { "name": "TeamHeader", "instances": 0 }, + { "name": "BillingHeader", "instances": 0 } + ], + "queries": [ + { "terms": ["Team Members"], "status": "ok", "top": "TeamHeader" }, + { "terms": ["Membres de l'Γ©quipe"], "status": "ok", "top": "TeamHeader" }, + { "terms": ["Invite teammate"], "status": "ok", "top": "TeamHeader" }, + { "terms": ["Billing overview"], "status": "ok", "top": "BillingHeader" }, + { "terms": ["AperΓ§u de la facturation"], "status": "ok", "top": "BillingHeader" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index baf2ea4..1df98ce 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -2,3 +2,4 @@ {"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} +{"generatedAt":"2026-07-13T10:02:55.141Z","commitSha":"67411c5662523fd633383013f6a0591ac3faea3c","pass":67,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} diff --git a/eval/src/golden.ts b/eval/src/golden.ts index 6dd00b1..f4cfec6 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -46,6 +46,12 @@ export interface Golden { note?: string; /** App directory relative to the fixture dir. Default "./app". */ app?: string; + /** Extra scan options this fixture needs (passed through to scanReact). */ + scan?: { + baseUrls?: string[]; + apiWrappers?: string[]; + i18n?: { localeGlobs: string[]; defaultLocale: string }; + }; expect: { components?: GoldenComponent[]; attributions?: GoldenAttribution[]; diff --git a/eval/src/run.ts b/eval/src/run.ts index fae645c..641412d 100644 --- a/eval/src/run.ts +++ b/eval/src/run.ts @@ -40,7 +40,7 @@ function main(): void { } const golden = JSON.parse(fs.readFileSync(goldenPath, "utf-8")) as Golden; const appDir = path.resolve(fixtureDir, golden.app ?? "./app"); - const graph = resolveHookEdges(scanReact({ root: appDir })); + const graph = resolveHookEdges(scanReact({ root: appDir, ...(golden.scan ?? {}) })); results.push(runChecks(name, golden, graph)); } diff --git a/packages/core/src/query.test.ts b/packages/core/src/query.test.ts index 96cb49c..bce0e9c 100644 --- a/packages/core/src/query.test.ts +++ b/packages/core/src/query.test.ts @@ -21,7 +21,7 @@ function component(file: string, name: string, renderedText: string[]): Componen loc: loc(file), exportName: name, props: [], - renderedText, + renderedText: renderedText.map((text) => ({ text, source: "jsx" as const })), rendersComponents: [], }; } diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 849b06c..7a1d034 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -52,16 +52,20 @@ export function matchComponentsByText( for (const node of graph.nodes) { if (node.kind !== "component") continue; - const haystack = node.renderedText.map((t) => t.toLowerCase()); const matchedText: string[] = []; const evidence: Evidence[] = []; for (const needle of needles) { - const hit = haystack.find((h) => h.includes(needle) || needle.includes(h)); + const hit = node.renderedText.find((entry) => { + const haystack = entry.text.toLowerCase(); + return haystack.includes(needle) || needle.includes(haystack); + }); if (hit !== undefined) { - matchedText.push(hit); + matchedText.push(hit.text.toLowerCase()); + const provenance = + hit.source === "i18n" ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` : ""; evidence.push({ kind: "text-match", - detail: `"${needle}" matched rendered text "${hit}"`, + detail: `"${needle}" matched rendered text "${hit.text}"${provenance}`, loc: node.loc, }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8d6b6ec..313bdeb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -42,6 +42,23 @@ export interface BaseNode { flags?: string[]; } +/** One piece of text a component can render, with its provenance. */ +export interface RenderedText { + text: string; + /** + * Where the text comes from: JSX children, a string attribute + * (placeholder/label/title/alt/aria-label), or an i18n key resolved + * against locale files. + */ + source: "jsx" | "attribute" | "i18n"; + /** i18n entries only: the translation key, e.g. "team.title". */ + key?: string; + /** i18n entries only: which locale this text belongs to. */ + locale?: string; + /** Condition source text when the text renders only in a branch (step 1.5). */ + branch?: string; +} + /** A React component definition β€” the code, not a usage. */ export interface ComponentNode extends BaseNode { kind: "component"; @@ -50,11 +67,11 @@ export interface ComponentNode extends BaseNode { /** Prop names destructured or accessed from the props object. */ props: string[]; /** - * Static text visible in the rendered output (JSX text, string literals in - * attributes like placeholder/label/title/alt/aria-label). This is the - * primary signal for matching a screenshot to a component. + * Static text visible in the rendered output β€” the primary signal for + * matching a screenshot to a component. i18n keys are expanded to one + * entry per locale, so a French screenshot matches the same component. */ - renderedText: string[]; + renderedText: RenderedText[]; /** Names of components this component renders in its JSX (deduplicated). */ rendersComponents: string[]; } diff --git a/packages/parser-react/package.json b/packages/parser-react/package.json index dfe11b5..5fc9ca0 100644 --- a/packages/parser-react/package.json +++ b/packages/parser-react/package.json @@ -22,7 +22,8 @@ }, "dependencies": { "@coderadar/core": "workspace:*", - "ts-morph": "^24.0.0" + "ts-morph": "^24.0.0", + "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "^22.20.1", diff --git a/packages/parser-react/src/i18n.test.ts b/packages/parser-react/src/i18n.test.ts new file mode 100644 index 0000000..858cdf3 --- /dev/null +++ b/packages/parser-react/src/i18n.test.ts @@ -0,0 +1,63 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { matchComponentsByText } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { scanReact } from "./scan.js"; + +const fixture = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/a2-i18n-keys/app", +); + +const graph = scanReact({ + root: fixture, + i18n: { localeGlobs: ["locales/*.json"], defaultLocale: "en" }, +}); + +const teamHeader = graph.nodes.find((n) => n.kind === "component" && n.name === "TeamHeader"); +const billingHeader = graph.nodes.find( + (n) => n.kind === "component" && n.name === "BillingHeader", +); + +describe("i18n adapter (a2 fixture)", () => { + it("expands t() keys into one entry per locale with provenance", () => { + if (teamHeader?.kind !== "component") throw new Error("TeamHeader not found"); + const titles = teamHeader.renderedText.filter((e) => e.key === "team.title"); + expect(titles).toHaveLength(2); + expect(titles.map((e) => `${e.locale}:${e.text}`).sort()).toEqual([ + "en:Team Members", + "fr:Membres de l'Γ©quipe", + ]); + expect(titles.every((e) => e.source === "i18n")).toBe(true); + }); + + it("resolves attributes", () => { + if (billingHeader?.kind !== "component") throw new Error("BillingHeader not found"); + const texts = billingHeader.renderedText.map((e) => e.text); + expect(texts).toContain("Billing overview"); + expect(texts).toContain("AperΓ§u de la facturation"); + }); + + it("matches screenshot text in any locale to the same component", () => { + for (const term of ["Team Members", "Membres de l'Γ©quipe"]) { + const result = matchComponentsByText(graph, [term]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("TeamHeader"); + } + }); + + it("records the i18n key in the match evidence", () => { + const result = matchComponentsByText(graph, ["Membres de l'Γ©quipe"]); + expect(result.candidates[0]?.evidence[0]?.detail).toContain("team.title"); + expect(result.candidates[0]?.evidence[0]?.detail).toContain("locale fr"); + }); + + it("skips i18n expansion entirely when unconfigured", () => { + const bare = scanReact({ root: fixture }); + const header = bare.nodes.find((n) => n.kind === "component" && n.name === "TeamHeader"); + if (header?.kind !== "component") throw new Error("TeamHeader not found"); + expect(header.renderedText.some((e) => e.source === "i18n")).toBe(false); + }); +}); diff --git a/packages/parser-react/src/i18n.ts b/packages/parser-react/src/i18n.ts new file mode 100644 index 0000000..2116b3d --- /dev/null +++ b/packages/parser-react/src/i18n.ts @@ -0,0 +1,146 @@ +/** + * i18n adapter (TRACKER step 1.4, failure mode A2). + * + * Source contains `t("team.title")`; the screenshot shows "Team Members" β€” + * or "Membres de l'Γ©quipe" when the reporter runs the app in French. The + * literal lives in locale files, not JSX. This module loads those files into + * a key β†’ text-per-locale table so every t()/ call site expands to one + * RenderedText entry per locale. + */ + +import fs from "node:fs"; +import path from "node:path"; + +import type { RenderedText } from "@coderadar/core"; +import { Node, SyntaxKind } from "ts-morph"; +import YAML from "yaml"; + +export interface I18nOptions { + /** Globs relative to the scan root, e.g. ["locales/*.json", "i18n/**\/*.yaml"]. */ + localeGlobs: string[]; + /** Locale reported first / used when a file's locale can't be inferred. */ + defaultLocale: string; +} + +/** key β†’ (locale β†’ text) */ +export type LocaleTable = ReadonlyMap>; + +const LOCALE_CODE = /^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/; + +export function loadLocaleTable(root: string, options: I18nOptions): LocaleTable { + const table = new Map>(); + for (const pattern of options.localeGlobs) { + for (const file of globFiles(root, pattern)) { + const locale = inferLocale(path.relative(root, file), options.defaultLocale); + const content = fs.readFileSync(file, "utf-8"); + const parsed: unknown = file.endsWith(".json") ? JSON.parse(content) : YAML.parse(content); + if (typeof parsed !== "object" || parsed === null) continue; + for (const [key, text] of flatten(parsed as Record, "")) { + let perLocale = table.get(key); + if (perLocale === undefined) { + perLocale = new Map(); + table.set(key, perLocale); + } + perLocale.set(locale, text); + } + } + } + return table; +} + +/** Expand every i18n key used in `body` into per-locale RenderedText entries. */ +export function i18nRenderedText(body: Node, table: LocaleTable): RenderedText[] { + const entries: RenderedText[] = []; + for (const key of collectI18nKeys(body)) { + const perLocale = lookup(table, key); + if (perLocale === undefined) continue; + for (const [locale, text] of perLocale) { + entries.push({ text, source: "i18n", key, locale }); + } + } + return entries; +} + +/** t("key") / i18n.t("key") calls and attributes. */ +function collectI18nKeys(body: Node): Set { + const keys = new Set(); + for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression().getText(); + if (callee !== "t" && !callee.endsWith(".t")) continue; + const arg = call.getArguments()[0]; + if (arg !== undefined && Node.isStringLiteral(arg)) keys.add(arg.getLiteralValue()); + } + for (const attr of body.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { + if (attr.getNameNode().getText() !== "i18nKey") continue; + const init = attr.getInitializer(); + if (init !== undefined && Node.isStringLiteral(init)) keys.add(init.getLiteralValue()); + } + return keys; +} + +/** Try the key as written, then without an i18next "namespace:" prefix. */ +function lookup(table: LocaleTable, key: string): ReadonlyMap | undefined { + const direct = table.get(key); + if (direct !== undefined) return direct; + const colon = key.indexOf(":"); + return colon >= 0 ? table.get(key.slice(colon + 1)) : undefined; +} + +function flatten(value: Record, prefix: string): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const [key, child] of Object.entries(value)) { + const full = prefix.length > 0 ? `${prefix}.${key}` : key; + if (typeof child === "string") { + out.push([full, child]); + } else if (typeof child === "object" && child !== null) { + out.push(...flatten(child as Record, full)); + } + } + return out; +} + +/** "locales/fr.json" β†’ "fr"; "i18n/de/common.yaml" β†’ "de"; fallback otherwise. */ +function inferLocale(relativeFile: string, fallback: string): string { + const base = path.basename(relativeFile, path.extname(relativeFile)); + if (LOCALE_CODE.test(base)) return base; + const dir = path.basename(path.dirname(relativeFile)); + if (LOCALE_CODE.test(dir)) return dir; + return fallback; +} + +/** Minimal glob: `**` crosses directories, `*` stays within one segment. */ +function globFiles(root: string, pattern: string): string[] { + const regex = new RegExp( + "^" + + pattern + .split(/(\*\*\/|\*\*|\*)/) + .map((part) => { + if (part === "**/" ) return "(?:.*/)?"; + if (part === "**") return ".*"; + if (part === "*") return "[^/]*"; + return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + }) + .join("") + + "$", + ); + const results: string[] = []; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (regex.test(path.relative(root, full).split(path.sep).join("/"))) { + results.push(full); + } + } + }; + walk(root); + return results.sort(); +} diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index d085719..ef08ee6 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -8,6 +8,7 @@ import { type LineageGraph, type LineageNode, nodeId, + type RenderedText, type SourceLocation, } from "@coderadar/core"; import { @@ -25,6 +26,7 @@ import { } from "ts-morph"; import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; +import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; import { detectWrappers, type WrapperRegistry } from "./wrappers.js"; export interface ScanOptions { @@ -43,6 +45,12 @@ export interface ScanOptions { * for clients the heuristic can't see. Heuristic detection runs regardless. */ apiWrappers?: string[]; + /** + * Locale-file configuration. When set, t("key") / call + * sites expand into renderedText entries for every locale, so screenshots + * in any language match. + */ + i18n?: I18nOptions; } type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression; @@ -97,6 +105,7 @@ export function scanReact(options: ScanOptions): LineageGraph { } const wrappers = detectWrappers(project, options.apiWrappers ?? []); + const localeTable = options.i18n !== undefined ? loadLocaleTable(root, options.i18n) : null; const nodes = new Map(); const edges: LineageEdge[] = []; const pendingInstances: PendingInstance[] = []; @@ -124,7 +133,10 @@ export function scanReact(options: ScanOptions): LineageGraph { loc: decl.loc, exportName: decl.exportName, props: extractProps(decl.fn), - renderedText: extractRenderedText(decl.fn), + renderedText: [ + ...extractRenderedText(decl.fn), + ...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []), + ], rendersComponents: extractRenderedComponents(decl.fn), }); collectInstanceSites(decl, id, file, pendingInstances); @@ -229,21 +241,21 @@ function extractProps(fn: FunctionLike): string[] { return [first.getName()]; } -function extractRenderedText(fn: FunctionLike): string[] { - const texts = new Set(); +function extractRenderedText(fn: FunctionLike): RenderedText[] { + const entries = new Map(); for (const jsxText of fn.getDescendantsOfKind(SyntaxKind.JsxText)) { const text = jsxText.getText().replace(/\s+/g, " ").trim(); - if (text.length > 0) texts.add(text); + if (text.length > 0) entries.set(`jsx:${text}`, { text, source: "jsx" }); } for (const attr of fn.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { if (!TEXT_ATTRIBUTES.has(attr.getNameNode().getText())) continue; const init = attr.getInitializer(); if (init !== undefined && Node.isStringLiteral(init)) { const text = init.getLiteralValue().trim(); - if (text.length > 0) texts.add(text); + if (text.length > 0) entries.set(`attribute:${text}`, { text, source: "attribute" }); } } - return [...texts]; + return [...entries.values()]; } function extractRenderedComponents(fn: FunctionLike): string[] { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cce41e9..c9ec249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.20.1) + version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) packages/parser-react: dependencies: @@ -66,6 +66,9 @@ importers: ts-morph: specifier: ^24.0.0 version: 24.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@types/node': specifier: ^22.20.1 @@ -75,7 +78,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.20.1) + version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) packages: @@ -721,6 +724,11 @@ packages: engines: {node: '>=8'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: '@esbuild/aix-ppc64@0.28.1': @@ -907,13 +915,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@22.20.1) + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -1160,13 +1168,13 @@ snapshots: undici-types@6.21.0: {} - vite-node@3.2.4(@types/node@22.20.1): + vite-node@3.2.4(@types/node@22.20.1)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@22.20.1) + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -1181,7 +1189,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@22.20.1): + vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -1192,12 +1200,13 @@ snapshots: optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 + yaml: 2.9.0 - vitest@3.2.7(@types/node@22.20.1): + vitest@3.2.7(@types/node@22.20.1)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -1215,8 +1224,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@22.20.1) - vite-node: 3.2.4(@types/node@22.20.1) + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -1238,3 +1247,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + yaml@2.9.0: {} diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 69f5480..5be6601 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -131,9 +131,9 @@ "renderedText": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/RenderedText" }, - "description": "Static text visible in the rendered output (JSX text, string literals in attributes like placeholder/label/title/alt/aria-label). This is the primary signal for matching a screenshot to a component." + "description": "Static text visible in the rendered output β€” the primary signal for matching a screenshot to a component. i18n keys are expanded to one entry per locale, so a French screenshot matches the same component." }, "rendersComponents": { "type": "array", @@ -180,6 +180,41 @@ "additionalProperties": false, "description": "Where a node lives in the codebase." }, + "RenderedText": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "jsx", + "attribute", + "i18n" + ], + "description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), or an i18n key resolved against locale files." + }, + "key": { + "type": "string", + "description": "i18n entries only: the translation key, e.g. \"team.title\"." + }, + "locale": { + "type": "string", + "description": "i18n entries only: which locale this text belongs to." + }, + "branch": { + "type": "string", + "description": "Condition source text when the text renders only in a branch (step 1.5)." + } + }, + "required": [ + "text", + "source" + ], + "additionalProperties": false, + "description": "One piece of text a component can render, with its provenance." + }, "InstanceNode": { "type": "object", "properties": { From 005a8c91c39181e3a6347165f3d2a9248d4a02cc Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:39:02 +0530 Subject: [PATCH 10/49] =?UTF-8?q?feat:=20rendered-text=20hardening=20?= =?UTF-8?q?=E2=80=94=20template=20wildcards,=20branch=20tagging,=20shared?= =?UTF-8?q?=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.5 (TRACKER). Failure modes A7 (extraction half), A8: - core/text.ts: normalizeText (lowercase, unicode-safe punctuation strip, whitespace collapse, naive plural folding) + textMatches (bidirectional containment with * wildcards) β€” ONE normalization used by extraction and matching, and later by the Phase 4 matcher. - Template JSX children extract as wildcard entries: {`${count} items in cart`} β†’ '* items in cart' (template: true), so the screenshot text '3 items in cart' matches. Resolvable template parts fold via 1.1. - Branch tagging: text guarded by ternaries, && gates, or early-return ifs carries branch: ; ternary else-branches negate. Evidence surfaces it: 'renders only when isAdmin'. Ternary string branches and {"literal"} JSX expressions are now extracted at all (found by the new a8 test failing). - Fixtures a7-transformed-text (CSS uppercase / template / plural queries) and a8-conditional-text (error/empty/admin states). - Scorecard: 77 pass / 0 fail / 2 xfail. 60 unit tests. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 6 +- .../a7-transformed-text/app/CartSummary.tsx | 9 +++ eval/fixtures/a7-transformed-text/golden.json | 13 ++++ .../a8-conditional-text/app/OrdersPanel.tsx | 25 +++++++ eval/fixtures/a8-conditional-text/golden.json | 13 ++++ eval/history.jsonl | 1 + packages/core/src/index.ts | 1 + packages/core/src/query.ts | 16 ++-- packages/core/src/text.test.ts | 36 +++++++++ packages/core/src/text.ts | 44 +++++++++++ packages/core/src/types.ts | 7 +- packages/parser-react/src/scan.ts | 74 ++++++++++++++++++- .../parser-react/src/text-hardening.test.ts | 68 +++++++++++++++++ schemas/lineage-graph.schema.json | 6 +- 14 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 eval/fixtures/a7-transformed-text/app/CartSummary.tsx create mode 100644 eval/fixtures/a7-transformed-text/golden.json create mode 100644 eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx create mode 100644 eval/fixtures/a8-conditional-text/golden.json create mode 100644 packages/core/src/text.test.ts create mode 100644 packages/core/src/text.ts create mode 100644 packages/parser-react/src/text-hardening.test.ts diff --git a/TRACKER.md b/TRACKER.md index 70c4d96..6cddfc1 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 1 β€” Robust extraction -- **Next step:** 1.5 β€” Rendered-text hardening -- **Done:** 0.1–0.4, 1.1–1.4 +- **Next step:** 1.6 β€” Legacy patterns & graceful degradation +- **Done:** 0.1–0.4, 1.1–1.5 - **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) ## What CodeRadar is @@ -118,7 +118,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. - `renderedText` becomes structured: `{ text: string, source: "jsx" | "attribute" | "i18n", branch?: string, locale?: string }[]` (schema addition β€” update JSON Schema + goldens). **Accept:** fixture `a2-i18n-keys` green: searching "Team Members" *and* "Γ‰quipe" both find the component. -### [ ] 1.5 Rendered-text hardening +### [x] 1.5 Rendered-text hardening **Failure modes:** A7 (extraction half), A8 **Build:** - Template text: `` `${count} items` `` β†’ `"* items"` with a `template: true` flag. diff --git a/eval/fixtures/a7-transformed-text/app/CartSummary.tsx b/eval/fixtures/a7-transformed-text/app/CartSummary.tsx new file mode 100644 index 0000000..6ba0cfb --- /dev/null +++ b/eval/fixtures/a7-transformed-text/app/CartSummary.tsx @@ -0,0 +1,9 @@ +export function CartSummary({ count, total }: { count: number; total: string }) { + return ( + + ); +} diff --git a/eval/fixtures/a7-transformed-text/golden.json b/eval/fixtures/a7-transformed-text/golden.json new file mode 100644 index 0000000..65f0e06 --- /dev/null +++ b/eval/fixtures/a7-transformed-text/golden.json @@ -0,0 +1,13 @@ +{ + "failureMode": "A7", + "note": "Runtime text transforms: screenshot shows 'SHOPPING CART' (CSS uppercase), '3 items in cart' (template literal), 'Total: $41.97'. Source has none of those exact strings. Normalization + template wildcards must bridge the gap.", + "expect": { + "components": [{ "name": "CartSummary", "instances": 0 }], + "queries": [ + { "terms": ["SHOPPING CART"], "status": "ok", "top": "CartSummary" }, + { "terms": ["3 items in cart"], "status": "ok", "top": "CartSummary" }, + { "terms": ["Total: $41.97"], "status": "ok", "top": "CartSummary" }, + { "terms": ["1 item in cart"], "status": "ok", "top": "CartSummary" } + ] + } +} diff --git a/eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx b/eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx new file mode 100644 index 0000000..06b05aa --- /dev/null +++ b/eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx @@ -0,0 +1,25 @@ +export function OrdersPanel({ + orders, + error, + isAdmin, +}: { + orders: string[]; + error: boolean; + isAdmin: boolean; +}) { + if (error) return

Could not load orders

; + if (orders.length === 0) return

No orders yet

; + + return ( +
+

Recent orders

+
    + {orders.map((o) => ( +
  • {o}
  • + ))} +
+ {isAdmin && } + {orders.length > 10 ? "Large order book" : "Small order book"} +
+ ); +} diff --git a/eval/fixtures/a8-conditional-text/golden.json b/eval/fixtures/a8-conditional-text/golden.json new file mode 100644 index 0000000..d59c921 --- /dev/null +++ b/eval/fixtures/a8-conditional-text/golden.json @@ -0,0 +1,13 @@ +{ + "failureMode": "A8", + "note": "Conditional branches: error/empty/admin states render text the happy path never shows. A screenshot of the error state must still find the component, and the branch condition rides along as evidence.", + "expect": { + "components": [{ "name": "OrdersPanel", "instances": 0 }], + "queries": [ + { "terms": ["Could not load orders"], "status": "ok", "top": "OrdersPanel" }, + { "terms": ["No orders yet"], "status": "ok", "top": "OrdersPanel" }, + { "terms": ["Export orders"], "status": "ok", "top": "OrdersPanel" }, + { "terms": ["Recent orders"], "status": "ok", "top": "OrdersPanel" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index 1df98ce..b7967de 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -3,3 +3,4 @@ {"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} {"generatedAt":"2026-07-13T10:02:55.141Z","commitSha":"67411c5662523fd633383013f6a0591ac3faea3c","pass":67,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} +{"generatedAt":"2026-07-13T10:09:02.527Z","commitSha":"4b4fd9e72204c47fa8ad523ff9b68fee41fc3a26","pass":77,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55f6be9..0a7310b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,4 @@ export * from "./types.js"; export * from "./result.js"; export * from "./query.js"; export * from "./storage.js"; +export * from "./text.js"; diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 7a1d034..9210eb1 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -14,6 +14,7 @@ import { ok, type QueryResult, } from "./result.js"; +import { normalizeText, textMatches } from "./text.js"; import type { ComponentNode, DataSourceNode, @@ -44,7 +45,7 @@ export function matchComponentsByText( graph: LineageGraph, terms: string[], ): QueryResult { - const needles = terms.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 1); + const needles = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1); if (needles.length === 0) return declined("no-signal"); const instancesByDefinition = groupInstances(graph); @@ -55,14 +56,17 @@ export function matchComponentsByText( const matchedText: string[] = []; const evidence: Evidence[] = []; for (const needle of needles) { - const hit = node.renderedText.find((entry) => { - const haystack = entry.text.toLowerCase(); - return haystack.includes(needle) || needle.includes(haystack); - }); + const hit = node.renderedText.find((entry) => + textMatches(normalizeText(entry.text), needle), + ); if (hit !== undefined) { matchedText.push(hit.text.toLowerCase()); const provenance = - hit.source === "i18n" ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` : ""; + hit.source === "i18n" + ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` + : hit.branch !== undefined + ? ` (renders only when ${hit.branch})` + : ""; evidence.push({ kind: "text-match", detail: `"${needle}" matched rendered text "${hit.text}"${provenance}`, diff --git a/packages/core/src/text.test.ts b/packages/core/src/text.test.ts new file mode 100644 index 0000000..6eff294 --- /dev/null +++ b/packages/core/src/text.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeText, textMatches } from "./text.js"; + +describe("normalizeText", () => { + it("lowercases, strips punctuation, collapses whitespace", () => { + expect(normalizeText(" SHOPPING CART! ")).toBe("shopping cart"); + expect(normalizeText("Total: $41.97")).toBe("total 41 97"); + }); + + it("keeps accents and * wildcards", () => { + expect(normalizeText("Membres de l'Γ©quipe")).toBe("membre de l Γ©quipe"); + expect(normalizeText("* items in cart")).toBe("* item in cart"); + }); + + it("folds naive plurals", () => { + expect(normalizeText("categories")).toBe("category"); + expect(normalizeText("items")).toBe("item"); + expect(normalizeText("class")).toBe("class"); + expect(normalizeText("gas")).toBe("gas"); + }); +}); + +describe("textMatches", () => { + it("matches containment in either direction", () => { + expect(textMatches("recent order", "recent")).toBe(true); + expect(textMatches("save", "save change")).toBe(true); + expect(textMatches("billing", "invoice")).toBe(false); + }); + + it("treats * as a wildcard", () => { + expect(textMatches("* item in cart", "3 item in cart")).toBe(true); + expect(textMatches("total *", "total 41 97")).toBe(true); + expect(textMatches("* item in cart", "empty cart")).toBe(false); + }); +}); diff --git a/packages/core/src/text.ts b/packages/core/src/text.ts new file mode 100644 index 0000000..03ab32f --- /dev/null +++ b/packages/core/src/text.ts @@ -0,0 +1,44 @@ +/** + * Text normalization shared by extraction (parser) and matching (query layer, + * Phase 4 matcher). Screenshot text never equals source text exactly β€” CSS + * uppercasing, punctuation, pluralization, OCR whitespace β€” so both sides of + * every comparison go through the same normalization (failure mode A7). + */ + +/** + * Lowercase, strip punctuation (unicode-aware β€” accents survive), collapse + * whitespace, and fold naive plurals ("items" β†’ "item", "categories" β†’ + * "category"). `*` survives because template entries use it as a wildcard. + */ +export function normalizeText(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}*\s]/gu, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .map(foldPlural) + .join(" "); +} + +/** + * Does `needle` (normalized) match `haystack` (normalized), where `haystack` + * may contain `*` wildcards from template text ("* item in cart" matches + * "3 items in cart")? + */ +export function textMatches(haystack: string, needle: string): boolean { + if (haystack.includes("*")) { + const pattern = haystack + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + return new RegExp(pattern).test(needle); + } + return haystack.includes(needle) || needle.includes(haystack); +} + +function foldPlural(token: string): string { + if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`; + if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1); + return token; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 313bdeb..060189b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -55,8 +55,13 @@ export interface RenderedText { key?: string; /** i18n entries only: which locale this text belongs to. */ locale?: string; - /** Condition source text when the text renders only in a branch (step 1.5). */ + /** Condition source text when the text renders only in a branch. */ branch?: string; + /** + * True when the text came from a template literal with runtime parts β€” + * unknown segments appear as `*` ("* items in cart") and match as wildcards. + */ + template?: boolean; } /** A React component definition β€” the code, not a usage. */ diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index ef08ee6..1cd969b 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -25,7 +25,7 @@ import { type VariableDeclaration, } from "ts-morph"; -import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; +import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js"; import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; import { detectWrappers, type WrapperRegistry } from "./wrappers.js"; @@ -243,21 +243,89 @@ function extractProps(fn: FunctionLike): string[] { function extractRenderedText(fn: FunctionLike): RenderedText[] { const entries = new Map(); + const add = (entry: RenderedText): void => { + entries.set(`${entry.source}:${entry.text}:${entry.branch ?? ""}`, entry); + }; + for (const jsxText of fn.getDescendantsOfKind(SyntaxKind.JsxText)) { const text = jsxText.getText().replace(/\s+/g, " ").trim(); - if (text.length > 0) entries.set(`jsx:${text}`, { text, source: "jsx" }); + if (text.length === 0) continue; + add({ text, source: "jsx", ...branchTag(jsxText, fn) }); } + for (const attr of fn.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { if (!TEXT_ATTRIBUTES.has(attr.getNameNode().getText())) continue; const init = attr.getInitializer(); if (init !== undefined && Node.isStringLiteral(init)) { const text = init.getLiteralValue().trim(); - if (text.length > 0) entries.set(`attribute:${text}`, { text, source: "attribute" }); + if (text.length > 0) add({ text, source: "attribute", ...branchTag(attr, fn) }); + } + } + + // Template literals rendered as JSX children: {`${count} items in cart`} β†’ + // "* items in cart" (unknown segments become * wildcards). + for (const expr of fn.getDescendantsOfKind(SyntaxKind.JsxExpression)) { + const inner = expr.getExpression(); + if (inner === undefined) continue; + if (Node.isStringLiteral(inner)) { + const text = inner.getLiteralValue().replace(/\s+/g, " ").trim(); + if (text.length > 0) add({ text, source: "jsx", ...branchTag(expr, fn) }); + } else if (Node.isConditionalExpression(inner)) { + // {cond ? "Large order book" : "Small order book"} + for (const branchNode of [inner.getWhenTrue(), inner.getWhenFalse()]) { + if (Node.isStringLiteral(branchNode)) { + const text = branchNode.getLiteralValue().replace(/\s+/g, " ").trim(); + if (text.length > 0) add({ text, source: "jsx", ...branchTag(branchNode, fn) }); + } + } + } else if (Node.isNoSubstitutionTemplateLiteral(inner)) { + const text = inner.getLiteralValue().replace(/\s+/g, " ").trim(); + if (text.length > 0) add({ text, source: "jsx", ...branchTag(expr, fn) }); + } else if (Node.isTemplateExpression(inner)) { + let text = inner.getHead().getLiteralText(); + for (const span of inner.getTemplateSpans()) { + text += `${resolveStringValue(span.getExpression(), 0) ?? "*"}${span.getLiteral().getLiteralText()}`; + } + text = text.replace(/\s+/g, " ").trim(); + if (text.replace(/[*\s]/g, "").length > 0) { + add({ text, source: "jsx", template: true, ...branchTag(expr, fn) }); + } } } + return [...entries.values()]; } +/** + * The nearest condition guarding a rendered-text node: a ternary branch, a + * `cond && ` gate, or an enclosing if-statement (early-return pattern). + * Ternary else-branches are negated. Empty object when unconditional. + */ +function branchTag(node: Node, boundary: FunctionLike): { branch?: string } { + let current: Node | undefined = node; + while (current !== undefined && current !== boundary) { + const parent: Node | undefined = current.getParent(); + if (parent === undefined) break; + if (Node.isConditionalExpression(parent)) { + const condition = parent.getCondition().getText(); + if (parent.getWhenTrue() === current) return { branch: condition }; + if (parent.getWhenFalse() === current) return { branch: `!(${condition})` }; + } + if ( + Node.isBinaryExpression(parent) && + parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken && + parent.getRight() === current + ) { + return { branch: parent.getLeft().getText() }; + } + if (Node.isIfStatement(parent) && parent.getThenStatement() === current) { + return { branch: parent.getExpression().getText() }; + } + current = parent; + } + return {}; +} + function extractRenderedComponents(fn: FunctionLike): string[] { const names = new Set(); const record = (tagName: string): void => { diff --git a/packages/parser-react/src/text-hardening.test.ts b/packages/parser-react/src/text-hardening.test.ts new file mode 100644 index 0000000..5fa93af --- /dev/null +++ b/packages/parser-react/src/text-hardening.test.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { matchComponentsByText } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { scanReact } from "./scan.js"; + +const fixtures = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures", +); + +function componentText(fixture: string, name: string) { + const graph = scanReact({ root: path.join(fixtures, fixture, "app") }); + const node = graph.nodes.find((n) => n.kind === "component" && n.name === name); + if (node?.kind !== "component") throw new Error(`${name} not found`); + return { graph, renderedText: node.renderedText }; +} + +describe("template text (a7 fixture)", () => { + const { graph, renderedText } = componentText("a7-transformed-text", "CartSummary"); + + it("extracts template literals with * wildcards and a template flag", () => { + const items = renderedText.find((e) => e.text === "* items in cart"); + expect(items?.template).toBe(true); + expect(renderedText.some((e) => e.text === "Total: *")).toBe(true); + }); + + it("matches concrete screenshot text against the wildcard", () => { + const result = matchComponentsByText(graph, ["3 items in cart"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("CartSummary"); + }); + + it("matches case-transformed and singular/plural variants", () => { + for (const term of ["SHOPPING CART", "1 item in cart"]) { + expect(matchComponentsByText(graph, [term]).status).toBe("ok"); + } + }); +}); + +describe("branch tagging (a8 fixture)", () => { + const { graph, renderedText } = componentText("a8-conditional-text", "OrdersPanel"); + + it("tags early-return branches with their condition", () => { + expect(renderedText.find((e) => e.text === "Could not load orders")?.branch).toBe("error"); + expect(renderedText.find((e) => e.text === "No orders yet")?.branch).toBe( + "orders.length === 0", + ); + }); + + it("tags && gates and ternary branches (negated on the else side)", () => { + expect(renderedText.find((e) => e.text === "Export orders")?.branch).toBe("isAdmin"); + expect(renderedText.find((e) => e.text === "Large order book")?.branch).toBe( + "orders.length > 10", + ); + expect(renderedText.find((e) => e.text === "Small order book")?.branch).toBe( + "!(orders.length > 10)", + ); + }); + + it("leaves unconditional text untagged and surfaces the branch in evidence", () => { + expect(renderedText.find((e) => e.text === "Recent orders")?.branch).toBeUndefined(); + const result = matchComponentsByText(graph, ["Export orders"]); + expect(result.candidates[0]?.evidence[0]?.detail).toContain("renders only when isAdmin"); + }); +}); diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 5be6601..4eec9e8 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -205,7 +205,11 @@ }, "branch": { "type": "string", - "description": "Condition source text when the text renders only in a branch (step 1.5)." + "description": "Condition source text when the text renders only in a branch." + }, + "template": { + "type": "boolean", + "description": "True when the text came from a template literal with runtime parts β€” unknown segments appear as `*` (\"* items in cart\") and match as wildcards." } }, "required": [ From 5bc6e00192bbfa38f87b514fee9028fe4b6f7d93 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 16:34:05 +0530 Subject: [PATCH 11/49] =?UTF-8?q?feat(parser-react):=20class=20components,?= =?UTF-8?q?=20HOC=20unwrapping,=20graceful=20degradation=20=E2=80=94=20Gat?= =?UTF-8?q?e=201=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.6 (TRACKER). Failure mode D4; closes Phase 1: - Class components: React.Component/PureComponent/bare Component subclasses scanned β€” render() is the text/instance/event body, the whole class body yields lifecycle fetches (componentDidMount), this.state keys become class-state nodes, this.props.X accesses become props, and method- reference handlers (onClick={this.refresh}) resolve. - HOC unwrapping: const Panel = connect(map)(PanelInner) records an alias; call sites materialize as instances OF PanelInner (alias chain bounded at 3). Eval components check now counts instances by definitionId, not tag name β€” the semantically correct reading the HOC case exposed. - Graceful degradation: a class with no render() emits an incomplete-flagged node instead of vanishing; coderadar scan prints the incomplete count. - Body-walking helpers genericized from FunctionLike to Node so classes and functions share extraction (extractRenderedText/BodyFacts/InstanceSites). - Scorecard: 91 pass / 0 fail / 2 xfail; precision 1.000, recall 0.895. 67 unit tests. GATE 1 PASSES. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 10 +- .../d4-class-components/app/BrokenPanel.tsx | 8 + .../app/ConnectedPanel.tsx | 27 +++ .../d4-class-components/app/OrdersBoard.tsx | 38 ++++ .../app/legacy/UserBadge.tsx | 11 + eval/fixtures/d4-class-components/golden.json | 32 +++ eval/history.jsonl | 1 + eval/src/checks.ts | 4 +- packages/cli/src/index.ts | 4 + packages/core/src/types.ts | 9 +- packages/parser-react/src/legacy.test.ts | 70 +++++++ packages/parser-react/src/scan.ts | 196 ++++++++++++++++-- schemas/lineage-graph.schema.json | 1 + 13 files changed, 387 insertions(+), 24 deletions(-) create mode 100644 eval/fixtures/d4-class-components/app/BrokenPanel.tsx create mode 100644 eval/fixtures/d4-class-components/app/ConnectedPanel.tsx create mode 100644 eval/fixtures/d4-class-components/app/OrdersBoard.tsx create mode 100644 eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx create mode 100644 eval/fixtures/d4-class-components/golden.json create mode 100644 packages/parser-react/src/legacy.test.ts diff --git a/TRACKER.md b/TRACKER.md index 6cddfc1..4845520 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -4,10 +4,10 @@ ## Status -- **Current phase:** 1 β€” Robust extraction -- **Next step:** 1.6 β€” Legacy patterns & graceful degradation -- **Done:** 0.1–0.4, 1.1–1.5 -- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) +- **Current phase:** 2 β€” Instance graph & cross-file data flow +- **Next step:** 2.1 β€” Instance tree construction +- **Done:** 0.1–0.4, 1.1–1.6 +- **Gates passed:** Gate 0 (CI + red-path, PRs #5/#6) Β· Gate 1 (precision 1.000 β‰₯ 0.90, recall 0.895 β‰₯ 0.80 across C2/C3/C5/A2/A7/A8/D4, zero forbidden hits) ## What CodeRadar is @@ -126,7 +126,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. - Normalization utility in core (shared with the Phase 4 matcher): lowercase, collapse whitespace, strip punctuation, naive singular/plural folding. **Accept:** fixtures `a7-transformed-text`, `a8-conditional-text` green (error-state text findable and tagged with its branch). -### [ ] 1.6 Legacy patterns & graceful degradation +### [x] 1.6 Legacy patterns & graceful degradation **Failure modes:** D4 **Build:** - Class components: `render()` treated as the body; `this.state`/`setState` β†’ state nodes; lifecycle fetches detected. diff --git a/eval/fixtures/d4-class-components/app/BrokenPanel.tsx b/eval/fixtures/d4-class-components/app/BrokenPanel.tsx new file mode 100644 index 0000000..f1e10b8 --- /dev/null +++ b/eval/fixtures/d4-class-components/app/BrokenPanel.tsx @@ -0,0 +1,8 @@ +import { Component } from "react"; + +/** No render() β€” must degrade to an `incomplete`-flagged node, never vanish. */ +export class BrokenPanel extends Component { + helper() { + return "not a render method"; + } +} diff --git a/eval/fixtures/d4-class-components/app/ConnectedPanel.tsx b/eval/fixtures/d4-class-components/app/ConnectedPanel.tsx new file mode 100644 index 0000000..9c5c4a7 --- /dev/null +++ b/eval/fixtures/d4-class-components/app/ConnectedPanel.tsx @@ -0,0 +1,27 @@ +declare function connect(mapState: unknown): (component: T) => T; + +function PanelInner({ items }: { items: string[] }) { + return ( +
+

Connected panel body

+
    + {items.map((i) => ( +
  • {i}
  • + ))} +
+
+ ); +} + +const mapState = (state: { items: string[] }) => ({ items: state.items }); + +export const Panel = connect(mapState)(PanelInner); + +export function Dashboard() { + return ( +
+

Legacy dashboard

+ +
+ ); +} diff --git a/eval/fixtures/d4-class-components/app/OrdersBoard.tsx b/eval/fixtures/d4-class-components/app/OrdersBoard.tsx new file mode 100644 index 0000000..e1f917b --- /dev/null +++ b/eval/fixtures/d4-class-components/app/OrdersBoard.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface OrdersBoardState { + orders: string[]; + failed: boolean; +} + +export class OrdersBoard extends React.Component, OrdersBoardState> { + state: OrdersBoardState = { orders: [], failed: false }; + + componentDidMount() { + fetch("/api/orders") + .then((res) => res.json()) + .then((orders: string[]) => this.setState({ orders })) + .catch(() => this.setState({ failed: true })); + } + + refresh = () => { + fetch("/api/orders") + .then((res) => res.json()) + .then((orders: string[]) => this.setState({ orders })); + }; + + render() { + if (this.state.failed) return

Orders unavailable

; + return ( +
+

Orders board

+
    + {this.state.orders.map((o) => ( +
  • {o}
  • + ))} +
+ +
+ ); + } +} diff --git a/eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx b/eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx new file mode 100644 index 0000000..54cfc66 --- /dev/null +++ b/eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx @@ -0,0 +1,11 @@ +import { Component } from "react"; + +export class UserBadge extends Component<{ name: string }> { + render() { + return ( + + Signed in as {this.props.name} + + ); + } +} diff --git a/eval/fixtures/d4-class-components/golden.json b/eval/fixtures/d4-class-components/golden.json new file mode 100644 index 0000000..2734564 --- /dev/null +++ b/eval/fixtures/d4-class-components/golden.json @@ -0,0 +1,32 @@ +{ + "failureMode": "D4", + "note": "Legacy patterns: class components (React.Component and bare Component), lifecycle fetches, this.state, HOC-wrapped components (connect(map)(Inner)), and a class with no render() that must degrade to an incomplete-flagged node instead of vanishing.", + "expect": { + "components": [ + { "name": "OrdersBoard", "instances": 0 }, + { "name": "UserBadge", "instances": 0 }, + { "name": "PanelInner", "instances": 1 }, + { "name": "Dashboard", "instances": 0 }, + { "name": "BrokenPanel", "instances": 0 } + ], + "attributions": [ + { "component": "OrdersBoard", "endpoints": ["/api/orders"] }, + { "component": "UserBadge", "endpoints": [] }, + { "component": "Dashboard", "endpoints": [] } + ], + "forbidden": [ + { + "component": "OrdersBoard", + "endpoint": "", + "note": "lifecycle fetch collapsed to dynamic means class scanning regressed" + } + ], + "queries": [ + { "terms": ["Orders board"], "status": "ok", "top": "OrdersBoard" }, + { "terms": ["Orders unavailable"], "status": "ok", "top": "OrdersBoard" }, + { "terms": ["Signed in as"], "status": "ok", "top": "UserBadge" }, + { "terms": ["Connected panel body"], "status": "ok", "top": "PanelInner" }, + { "terms": ["Legacy dashboard"], "status": "ok", "top": "Dashboard" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index b7967de..c5b77c8 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -4,3 +4,4 @@ {"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} {"generatedAt":"2026-07-13T10:02:55.141Z","commitSha":"67411c5662523fd633383013f6a0591ac3faea3c","pass":67,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} {"generatedAt":"2026-07-13T10:09:02.527Z","commitSha":"4b4fd9e72204c47fa8ad523ff9b68fee41fc3a26","pass":77,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} +{"generatedAt":"2026-07-13T11:04:05.130Z","commitSha":"d13b90dd99c993889f57218997984e3e2e601cfd","pass":91,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index f23e443..3bc0f89 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -35,8 +35,10 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): const definition = graph.nodes.find( (n) => n.kind === "component" && n.name === expected.name, ); + // Count by definition, not by tag name: (an HOC alias) is an + // instance OF PanelInner even though the JSX never says "PanelInner". const instances = graph.nodes.filter( - (n) => n.kind === "instance" && n.name === expected.name, + (n) => n.kind === "instance" && definition !== undefined && n.definitionId === definition.id, ); const passed = definition !== undefined && instances.length === expected.instances; finalize( diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 563bfc9..68aa15e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,6 +43,10 @@ program ); for (const [kind, count] of [...counts].sort()) console.log(` ${kind}: ${count}`); console.log(` edges: ${graph.edges.length}`); + const incomplete = graph.nodes.filter((n) => n.flags?.includes("incomplete")).length; + if (incomplete > 0) { + console.log(` incomplete: ${incomplete} node(s) could not be fully parsed`); + } console.log(`Graph written to ${opts.out}`); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 060189b..826f348 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -145,7 +145,14 @@ export interface DataSourceNode extends BaseNode { queryKey?: string; } -export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown"; +export type StateKind = + | "useState" + | "useReducer" + | "context" + | "redux" + | "zustand" + | "class-state" + | "unknown"; /** Local or global state a component reads. */ export interface StateNode extends BaseNode { diff --git a/packages/parser-react/src/legacy.test.ts b/packages/parser-react/src/legacy.test.ts new file mode 100644 index 0000000..28b2a1d --- /dev/null +++ b/packages/parser-react/src/legacy.test.ts @@ -0,0 +1,70 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { traceLineage } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { scanReact } from "./scan.js"; + +const fixture = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/d4-class-components/app", +); + +const graph = scanReact({ root: fixture }); + +function componentNode(name: string) { + const node = graph.nodes.find((n) => n.kind === "component" && n.name === name); + if (node?.kind !== "component") throw new Error(`${name} not found`); + return node; +} + +describe("class components (d4 fixture)", () => { + it("detects React.Component and bare Component subclasses", () => { + expect(componentNode("OrdersBoard").exportName).toBe("OrdersBoard"); + expect(componentNode("UserBadge").renderedText.some((e) => e.text.includes("Signed in as"))).toBe( + true, + ); + }); + + it("attributes lifecycle fetches to the class component", () => { + const result = traceLineage(graph, componentNode("OrdersBoard").id); + const lineage = result.candidates[0]?.value; + expect(lineage?.dataSources.map((d) => d.endpoint)).toEqual(["/api/orders"]); + }); + + it("extracts this.state keys as class-state nodes", () => { + const result = traceLineage(graph, componentNode("OrdersBoard").id); + const state = result.candidates[0]?.value.state; + expect(state?.map((s) => `${s.stateKind}:${s.name}`).sort()).toEqual([ + "class-state:failed", + "class-state:orders", + ]); + }); + + it("captures method-reference event handlers (this.refresh)", () => { + const result = traceLineage(graph, componentNode("OrdersBoard").id); + const events = result.candidates[0]?.value.events; + expect(events?.map((e) => `${e.event}:${e.handler}`)).toEqual(["onClick:this.refresh"]); + }); + + it("collects this.props accesses as props", () => { + expect(componentNode("UserBadge").props).toEqual(["name"]); + }); +}); + +describe("HOC unwrapping (d4 fixture)", () => { + it("resolves through connect() to the inner component's definition", () => { + const instance = graph.nodes.find((n) => n.kind === "instance" && n.name === "Panel"); + if (instance?.kind !== "instance") throw new Error("Panel instance not found"); + expect(instance.definitionId).toBe(componentNode("PanelInner").id); + }); +}); + +describe("graceful degradation (d4 fixture)", () => { + it("emits an incomplete-flagged node for a class with no render()", () => { + const broken = componentNode("BrokenPanel"); + expect(broken.flags).toContain("incomplete"); + expect(broken.renderedText).toEqual([]); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 1cd969b..e05228a 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -109,6 +109,8 @@ export function scanReact(options: ScanOptions): LineageGraph { const nodes = new Map(); const edges: LineageEdge[] = []; const pendingInstances: PendingInstance[] = []; + /** HOC-wrapped alias β†’ inner component name, e.g. Panel β†’ PanelInner (connect). */ + const hocAliases = new Map(); const addEdge = (edge: LineageEdge): void => { if (!edges.some((e) => e.from === edge.from && e.to === edge.to && e.kind === edge.kind)) { edges.push(edge); @@ -139,16 +141,19 @@ export function scanReact(options: ScanOptions): LineageGraph { ], rendersComponents: extractRenderedComponents(decl.fn), }); - collectInstanceSites(decl, id, file, pendingInstances); + collectInstanceSites(decl.fn, id, file, pendingInstances); } else { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls, wrappers); + extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers); } + + scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances); + collectHocAliases(sourceFile, hocAliases); } - materializeInstances(pendingInstances, nodes, addEdge); + materializeInstances(pendingInstances, nodes, addEdge, hocAliases); return { version: 2, @@ -241,7 +246,7 @@ function extractProps(fn: FunctionLike): string[] { return [first.getName()]; } -function extractRenderedText(fn: FunctionLike): RenderedText[] { +function extractRenderedText(fn: Node): RenderedText[] { const entries = new Map(); const add = (entry: RenderedText): void => { entries.set(`${entry.source}:${entry.text}:${entry.branch ?? ""}`, entry); @@ -301,7 +306,7 @@ function extractRenderedText(fn: FunctionLike): RenderedText[] { * `cond && ` gate, or an enclosing if-statement (early-return pattern). * Ternary else-branches are negated. Empty object when unconditional. */ -function branchTag(node: Node, boundary: FunctionLike): { branch?: string } { +function branchTag(node: Node, boundary: Node): { branch?: string } { let current: Node | undefined = node; while (current !== undefined && current !== boundary) { const parent: Node | undefined = current.getParent(); @@ -326,7 +331,7 @@ function branchTag(node: Node, boundary: FunctionLike): { branch?: string } { return {}; } -function extractRenderedComponents(fn: FunctionLike): string[] { +function extractRenderedComponents(fn: Node): string[] { const names = new Set(); const record = (tagName: string): void => { const head = tagName.split(".")[0]; @@ -347,7 +352,8 @@ function extractRenderedComponents(fn: FunctionLike): string[] { * and same-file hook calls. */ function extractBodyFacts( - decl: Declaration, + declName: string, + body: Node, ownerId: string, file: string, nodes: Map, @@ -358,9 +364,9 @@ function extractBodyFacts( // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a // data source emitted here would attribute ":path" to every consumer. Call // sites get the real, substituted endpoint instead. - const declIsWrapper = wrappers.has(decl.name); + const declIsWrapper = wrappers.has(declName); - for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) { + for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { const callee = call.getExpression().getText(); const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers); @@ -387,7 +393,7 @@ function extractBodyFacts( const stateKind = detectState(callee); if (stateKind !== null) { const stateName = stateVariableName(call) ?? callee; - const stId = nodeId("state", file, `${decl.name}.${stateName}`); + const stId = nodeId("state", file, `${declName}.${stateName}`); if (!nodes.has(stId)) { nodes.set(stId, { id: stId, @@ -407,16 +413,22 @@ function extractBodyFacts( } } - for (const attr of decl.fn.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { + for (const attr of body.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { const attrName = attr.getNameNode().getText(); if (!/^on[A-Z]/.test(attrName)) continue; const init = attr.getInitializer(); let handler: string | null = null; if (init !== undefined && Node.isJsxExpression(init)) { const expr = init.getExpression(); - if (expr !== undefined && Node.isIdentifier(expr)) handler = expr.getText(); + // Plain references (handleDelete) and method references (this.refresh). + if ( + expr !== undefined && + (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) + ) { + handler = expr.getText(); + } } - const evId = nodeId("event", file, `${decl.name}.${attrName}${handler !== null ? `:${handler}` : ""}`); + const evId = nodeId("event", file, `${declName}.${attrName}${handler !== null ? `:${handler}` : ""}`); if (!nodes.has(evId)) { nodes.set(evId, { id: evId, @@ -616,9 +628,146 @@ function stateVariableName(call: CallExpression): string | null { return null; } +const CLASS_COMPONENT_BASE = /(React\.)?(Pure)?Component/; + +/** Legacy class components: render() is the body, this.state is state, lifecycle fetches count. */ +function scanClassComponents( + sourceFile: SourceFile, + file: string, + nodes: Map, + addEdge: (edge: LineageEdge) => void, + baseUrls: string[], + wrappers: WrapperRegistry, + localeTable: LocaleTable | null, + pendingInstances: PendingInstance[], +): void { + for (const cls of sourceFile.getClasses()) { + const name = cls.getName(); + if (name === undefined || !COMPONENT_NAME.test(name)) continue; + const heritage = cls.getExtends()?.getText() ?? ""; + if (!CLASS_COMPONENT_BASE.test(heritage)) continue; + + const id = nodeId("component", file, name); + const exportName = cls.isDefaultExport() ? "default" : cls.isExported() ? name : null; + const render = cls.getMethod("render"); + + if (render === undefined) { + nodes.set(id, { + id, + kind: "component", + name, + loc: locOf(cls, file), + exportName, + props: [], + renderedText: [], + rendersComponents: [], + flags: ["incomplete"], + }); + continue; + } + + nodes.set(id, { + id, + kind: "component", + name, + loc: locOf(cls, file), + exportName, + props: classProps(cls), + renderedText: [ + ...extractRenderedText(render), + ...(localeTable !== null ? i18nRenderedText(render, localeTable) : []), + ], + rendersComponents: extractRenderedComponents(render), + }); + collectInstanceSites(render, id, file, pendingInstances); + // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. + extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers); + extractClassState(cls, name, id, file, nodes, addEdge); + } +} + +/** `this.props.` accesses stand in for destructured props. */ +function classProps(cls: Node): string[] { + const props = new Set(); + for (const access of cls.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + if (access.getExpression().getText() === "this.props") props.add(access.getName()); + } + return [...props]; +} + +/** State keys from `state = {...}` property or `this.state = {...}` in the constructor. */ +function extractClassState( + cls: Node, + componentName: string, + ownerId: string, + file: string, + nodes: Map, + addEdge: (edge: LineageEdge) => void, +): void { + const stateKeys = new Map(); + const collectKeys = (objectLiteral: Node): void => { + if (!Node.isObjectLiteralExpression(objectLiteral)) return; + for (const member of objectLiteral.getProperties()) { + if (Node.isPropertyAssignment(member) || Node.isShorthandPropertyAssignment(member)) { + stateKeys.set(member.getName(), member); + } + } + }; + for (const property of cls.getDescendantsOfKind(SyntaxKind.PropertyDeclaration)) { + if (property.getName() !== "state") continue; + const init = property.getInitializer(); + if (init !== undefined) collectKeys(init); + } + for (const assignment of cls.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (assignment.getLeft().getText() === "this.state") collectKeys(assignment.getRight()); + } + for (const [key, node] of stateKeys) { + const stId = nodeId("state", file, `${componentName}.${key}`); + if (!nodes.has(stId)) { + nodes.set(stId, { + id: stId, + kind: "state", + name: key, + loc: locOf(node, file), + stateKind: "class-state", + }); + } + addEdge({ from: ownerId, to: stId, kind: "reads-state" }); + } +} + +const HOC_NAMES = /^(connect|withRouter|memo|forwardRef|observer|inject|withTranslation|styled)\b/; + +/** + * `const Panel = connect(mapState)(PanelInner)` β€” record Panel β†’ PanelInner so + * call sites resolve to the inner component's definition. + */ +function collectHocAliases(sourceFile: SourceFile, hocAliases: Map): void { + for (const variable of sourceFile.getVariableDeclarations()) { + const name = variable.getName(); + if (!COMPONENT_NAME.test(name)) continue; + const init = variable.getInitializer(); + if (init === undefined || !Node.isCallExpression(init)) continue; + if (unwrapFunction(init) !== undefined) continue; // inline-fn HOCs are handled as declarations + const outermostCallee = init.getExpression().getText(); + if (!HOC_NAMES.test(outermostCallee)) continue; + const inner = findComponentArgument(init); + if (inner !== null && inner !== name) hocAliases.set(name, inner); + } +} + +/** First capitalized identifier argument anywhere in a (possibly curried) call chain. */ +function findComponentArgument(call: Node): string | null { + if (!Node.isCallExpression(call)) return null; + for (const arg of call.getArguments()) { + if (Node.isIdentifier(arg) && COMPONENT_NAME.test(arg.getText())) return arg.getText(); + } + return findComponentArgument(call.getExpression()); +} + /** Record every JSX call site of a capitalized component within a declaration body. */ function collectInstanceSites( - decl: Declaration, + body: Node, ownerId: string, file: string, pendingInstances: PendingInstance[], @@ -636,8 +785,8 @@ function collectInstanceSites( } pendingInstances.push({ tagName: head, loc: locOf(el, file), staticProps, ownerId, file }); }; - for (const el of decl.fn.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el); - for (const el of decl.fn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el); + for (const el of body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el); + for (const el of body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el); } /** @@ -652,14 +801,27 @@ function materializeInstances( pendingInstances: PendingInstance[], nodes: Map, addEdge: (edge: LineageEdge) => void, + hocAliases: ReadonlyMap, ): void { const definitionsByName = new Map(); for (const node of nodes.values()) { if (node.kind === "component") definitionsByName.set(node.name, node.id); } + // where Panel = connect(...)(PanelInner): resolve through the alias + // chain (bounded β€” alias graphs are tiny and could in principle cycle). + const resolveAlias = (tagName: string): string => { + let name = tagName; + for (let hop = 0; hop < 3; hop += 1) { + const target = hocAliases.get(name); + if (target === undefined) break; + name = target; + } + return name; + }; + for (const pending of pendingInstances) { - const definitionId = definitionsByName.get(pending.tagName); + const definitionId = definitionsByName.get(resolveAlias(pending.tagName)); if (definitionId === undefined || definitionId === pending.ownerId) continue; let id = instanceId(pending.file, pending.loc.line, pending.tagName); diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 4eec9e8..15cca29 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -446,6 +446,7 @@ "context", "redux", "zustand", + "class-state", "unknown" ] }, From d2b2da4234481f14b8514b3a2d95e7979b22976e Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 16:40:56 +0530 Subject: [PATCH 12/49] =?UTF-8?q?feat(parser-react):=20instance=20tree=20?= =?UTF-8?q?=E2=80=94=20import-resolved=20definitions,=20nesting,=20design-?= =?UTF-8?q?system=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2.1 (TRACKER). Failure modes C1 (graph half), A5: - JSX tags resolve through the file's imports via getExportedDeclarations: barrel files, renames (export { ProfileCardInner as ProfileCard }), and default-export aliases (export { default as Avatar }) all land on the original definition's node id. Same-file and unique-name fallbacks retained; HOC aliases compose with import resolution. - Design-system instances (A5): tags imported from configured designSystemPackages (or resolving into node_modules) materialize as instances flagged external-definition with definitionId external:# β€” the usage site is ours even when the definition isn't. Unconfigured unresolvable imports (react-i18next Trans etc.) stay excluded. - Same-body nesting: + + {open ?

Cart details

: null} + + ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx new file mode 100644 index 0000000..64ec5c6 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx @@ -0,0 +1,18 @@ +import { useNavigate } from "react-router-dom"; + +export function CheckoutPage() { + const navigate = useNavigate(); + + // Resolves to a known route β†’ navigates-to /cart. + const goBack = () => navigate("/cart"); + // Resolves to a path with no declared route β†’ flagged unresolved-nav. + const goBroken = () => navigate("/nowhere"); + + return ( +
+

Checkout

+ + +
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx new file mode 100644 index 0000000..8ac7097 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx @@ -0,0 +1,7 @@ +export function UserDetail() { + return ( +
+

User profile

+
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx new file mode 100644 index 0000000..544433c --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx @@ -0,0 +1,36 @@ +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import { fetchUsers } from "../store/usersSlice"; + +interface Row { + id: string; + name: string; +} + +export function UsersPage() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const rows: Row[] = []; + + // Local handler dispatching a thunk β†’ writes-state on the users slice. + const refreshUsers = () => dispatch(fetchUsers()); + // Local handler issuing a fetch β†’ triggers a data source. + const exportUsers = () => fetch("/api/users/export", { method: "POST" }); + + return ( +
+

All users

+ + +
    + {rows.map((row) => ( +
  • + {/* Inline navigate with a computed param β†’ /users/:id, shape-joined to the route. */} + +
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts b/eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts new file mode 100644 index 0000000..ca729c0 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts @@ -0,0 +1,18 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const cartSlice = createSlice({ + name: "cart", + initialState: { items: [] as string[] }, + reducers: { + // A plain reducer action: dispatch(clearCart()) writes this slice. + clearCart(state) { + state.items = []; + }, + addItem(state, action) { + state.items.push(action.payload); + }, + }, +}); + +export const { clearCart, addItem } = cartSlice.actions; +export default cartSlice.reducer; diff --git a/eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts b/eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts new file mode 100644 index 0000000..b728d9d --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts @@ -0,0 +1,25 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; + +// Thunk: the fetch lives here; dispatch(fetchUsers()) in a handler triggers it. +export const fetchUsers = createAsyncThunk("users/fetch", async () => { + const res = await fetch("/api/users"); + return res.json(); +}); + +const usersSlice = createSlice({ + name: "users", + initialState: { list: [] as string[] }, + reducers: { + selectUser(state, action) { + (state as { selected?: string }).selected = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetchUsers.fulfilled, (state, action) => { + state.list = action.payload; + }); + }, +}); + +export const { selectUser } = usersSlice.actions; +export default usersSlice.reducer; diff --git a/eval/fixtures/b3-programmatic-nav/golden.json b/eval/fixtures/b3-programmatic-nav/golden.json new file mode 100644 index 0000000..dd25c84 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/golden.json @@ -0,0 +1,29 @@ +{ + "failureMode": "B3", + "note": "Action effects: resolved handler bodies mined for effects, each a typed edge from the EventNode. navigate()/router.push β†’ navigates-to a RouteNode (computed target folded to :param form, joined by path shape); fetch in a handler β†’ triggers a data source; dispatch(thunk|action) β†’ writes-state on the slice; setState setter β†’ writes-state on the local slot. A navigate to a path with no declared route is flagged unresolved-nav, never silent.", + "expect": { + "components": [ + { "name": "UsersPage", "instances": 0 }, + { "name": "CartPage", "instances": 0 }, + { "name": "CheckoutPage", "instances": 0 } + ], + "routes": [ + { "path": "/users", "component": "UsersPage" }, + { "path": "/users/:userId", "component": "UserDetail" }, + { "path": "/cart", "component": "CartPage" }, + { "path": "/checkout", "component": "CheckoutPage" } + ], + "effects": [ + { "component": "UsersPage", "event": "onClick", "effect": "writes-state", "to": "users" }, + { "component": "UsersPage", "event": "onClick", "effect": "triggers", "to": "/api/users/export" }, + { "component": "UsersPage", "event": "onClick", "effect": "navigates-to", "to": "/users/:userId" }, + { "component": "CartPage", "event": "onClick", "effect": "writes-state", "to": "cart" }, + { "component": "CartPage", "event": "onClick", "effect": "writes-state", "to": "open" }, + { "component": "CheckoutPage", "event": "onClick", "effect": "navigates-to", "to": "/cart" } + ], + "queries": [ + { "terms": ["All users"], "status": "ok", "top": "UsersPage" }, + { "terms": ["Your cart"], "status": "ok", "top": "CartPage" } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index f9af332..f499edb 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -128,6 +128,43 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): }); } + for (const expected of golden.expect.effects ?? []) { + const id = `effect:${expected.component}.${expected.event}:${expected.effect}->${expected.to}`; + const owner = graph.nodes.find( + (n) => n.kind === "component" && n.name === expected.component, + ); + // Events the component handles, filtered to the named event (onClick, …). + const eventIds = new Set( + graph.edges + .filter((e) => e.kind === "handles" && owner !== undefined && e.from === owner.id) + .map((e) => e.to) + .filter((eventId) => { + const event = graph.nodes.find((n) => n.id === eventId); + return event?.kind === "event" && event.event === expected.event; + }), + ); + const matched = graph.edges.some((edge) => { + if (edge.kind !== expected.effect || !eventIds.has(edge.from)) return false; + const target = graph.nodes.find((n) => n.id === edge.to); + if (target === undefined) return false; + if (target.kind === "route") return target.path === expected.to; + if (target.kind === "data-source") return target.endpoint === expected.to; + if (target.kind === "state") return target.name === expected.to; + return false; + }); + finalize( + "effects", + id, + matched, + expected.expectedFail, + matched + ? undefined + : owner === undefined + ? "component not found" + : `no ${expected.effect} edge from a ${expected.component}.${expected.event} event to ${expected.to}`, + ); + } + for (const query of golden.expect.queries ?? []) { const id = `query:${query.terms.join("+")}`; const result = matchComponentsByText(graph, query.terms); diff --git a/eval/src/golden.ts b/eval/src/golden.ts index 7f7cfb3..7cbc479 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -53,6 +53,22 @@ export interface GoldenRoute { expectedFail?: string; } +export interface GoldenEffect { + /** Component that owns the event (a `handles` edge from it reaches the event). */ + component: string; + /** Event name the effect hangs off, e.g. "onClick", "onSubmit". */ + event: string; + /** The effect edge kind asserted from the event node. */ + effect: "navigates-to" | "triggers" | "writes-state"; + /** + * Expected target, matched against the edge's destination node: + * a route path (navigates-to), a data-source endpoint (triggers), or a + * state/slice name (writes-state). + */ + to: string; + expectedFail?: string; +} + export interface Golden { failureMode: string; note?: string; @@ -72,6 +88,8 @@ export interface Golden { routes?: GoldenRoute[]; /** Paths that must NOT exist as routes (api handlers, _app…). Never xfails. */ forbiddenRoutes?: string[]; + /** Action effects (step 3.2): event β†’ navigates-to / triggers / writes-state. */ + effects?: GoldenEffect[]; }; } @@ -80,7 +98,7 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass"; export interface CheckResult { /** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */ id: string; - kind: "components" | "attributions" | "forbidden" | "queries" | "routes"; + kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects"; status: CheckStatus; detail?: string; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index afb30e7..800c13b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -219,9 +219,10 @@ export type EdgeKind = | "fetches-from" // component | hook -> data-source | "provides-data" // data-source -> instance (via a prop; Phase 2.2) | "reads-state" // component | hook -> state - | "writes-state" // data-source | event -> state (Phase 2.4) + | "writes-state" // data-source | event -> state (Phase 2.4; dispatch effects Phase 3.2) | "handles" // component -> event - | "triggers" // event -> data-source | state (handler causes a fetch / state write) + | "triggers" // event -> data-source (resolved handler body issues a fetch; Phase 3.2) + | "navigates-to" // event -> route (handler calls navigate/router.push; Phase 3.2, B3) | "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/effects.test.ts b/packages/parser-react/src/effects.test.ts new file mode 100644 index 0000000..48ba09b --- /dev/null +++ b/packages/parser-react/src/effects.test.ts @@ -0,0 +1,92 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { LineageGraph, LineageNode } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const fixtures = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../eval/fixtures"); + +const b3 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b3-programmatic-nav/app") })); + +/** The effect target reachable from a `handler`-named event by an edge of `kind`. */ +function effectTargets(graph: LineageGraph, handler: string, kind: string): LineageNode[] { + const event = graph.nodes.find((n) => n.kind === "event" && n.handler === handler); + const inline = graph.nodes.filter((n) => n.kind === "event" && n.handler === null); + const events = event !== undefined ? [event] : inline; + const ids = new Set(events.map((e) => e.id)); + return graph.edges + .filter((e) => e.kind === kind && ids.has(e.from)) + .map((e) => graph.nodes.find((n) => n.id === e.to)) + .filter((n): n is LineageNode => n !== undefined); +} + +describe("action effects (b3 fixture, TRACKER 3.2)", () => { + it("navigate() in a handler β†’ navigates-to the matching route (exact path)", () => { + const targets = effectTargets(b3, "goBack", "navigates-to"); + expect(targets.map((t) => (t.kind === "route" ? t.path : t.id))).toEqual(["/cart"]); + }); + + it("navigate(`/users/${id}`) joins the route by param-agnostic shape", () => { + // Handler is an inline arrow (handler === null); match the /users/:userId route. + const routes = b3.edges + .filter((e) => e.kind === "navigates-to") + .map((e) => b3.nodes.find((n) => n.id === e.to)) + .filter((n) => n?.kind === "route"); + expect(routes.some((r) => r?.kind === "route" && r.path === "/users/:userId")).toBe(true); + }); + + it("a navigate to a path with no declared route is flagged, never silently dropped", () => { + const broken = b3.nodes.find((n) => n.kind === "event" && n.handler === "goBroken"); + expect(broken?.flags).toContain("unresolved-nav"); + expect(b3.edges.some((e) => e.kind === "navigates-to" && e.from === broken?.id)).toBe(false); + }); + + it("fetch() in a handler β†’ triggers a data source", () => { + const targets = effectTargets(b3, "exportUsers", "triggers"); + const endpoints = targets.map((t) => (t.kind === "data-source" ? t.endpoint : t.id)); + expect(endpoints).toContain("/api/users/export"); + }); + + it("dispatch(thunk()) β†’ writes-state on the slice the thunk populates", () => { + const targets = effectTargets(b3, "refreshUsers", "writes-state"); + const names = targets.map((t) => t.name); + expect(names).toContain("users"); + }); + + it("dispatch(action()) of a plain reducer β†’ writes-state on that slice", () => { + // Inline arrow onClick={() => dispatch(clearCart())}. + const writes = b3.edges + .filter((e) => e.kind === "writes-state") + .map((e) => b3.nodes.find((n) => n.id === e.to)) + .filter((n) => n?.kind === "state"); + const fromEvent = b3.edges.some((e) => { + if (e.kind !== "writes-state") return false; + const from = b3.nodes.find((n) => n.id === e.from); + const to = b3.nodes.find((n) => n.id === e.to); + return from?.kind === "event" && to?.kind === "state" && to.name === "cart"; + }); + expect(writes.some((n) => n?.name === "cart")).toBe(true); + expect(fromEvent).toBe(true); + }); + + it("a local setState setter β†’ writes-state on the local slot", () => { + const targets = effectTargets(b3, "showDetails", "writes-state"); + const names = targets.map((t) => t.name); + expect(names).toContain("open"); + const slot = targets.find((t) => t.name === "open"); + expect(slot?.kind === "state" ? slot.stateKind : undefined).toBe("useState"); + }); +}); + +describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => { + const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") })); + + it("a 4-level drilled handler still emits its triggers edge", () => { + const event = b1.nodes.find((n) => n.kind === "event" && n.handler === "onSave"); + const triggers = b1.edges.filter((e) => e.kind === "triggers" && e.from === event?.id); + const target = b1.nodes.find((n) => n.id === triggers[0]?.to); + expect(target?.kind === "data-source" ? target.endpoint : undefined).toBe("/api/drafts"); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index acb5e6d..4f55dde 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -134,6 +134,8 @@ export function scanReact(options: ScanOptions): LineageGraph { } }; const stores = detectStores(project, root, nodes, addEdge, baseUrls, wrappers); + /** Event node id β†’ the handler expressions it wires, mined for effects (3.2). */ + const handlerExprs = new Map(); for (const sourceFile of project.getSourceFiles()) { const file = toPosix(path.relative(root, sourceFile.getFilePath())); @@ -171,10 +173,10 @@ export function scanReact(options: ScanOptions): LineageGraph { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores); + extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs); } - scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores); + scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs); collectHocAliases(sourceFile, hocAliases); } @@ -187,8 +189,20 @@ export function scanReact(options: ScanOptions): LineageGraph { root, ); resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers); - resolveHandlerChains(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers, root); + // Routes first: navigate() effects (3.2) join to RouteNodes by path shape. detectRoutes(project, root, nodes, addEdge); + resolveHandlerChains( + pendingInstances, + instanceIds, + nodes, + edges, + addEdge, + baseUrls, + wrappers, + stores, + handlerExprs, + root, + ); return { version: 2, @@ -407,6 +421,7 @@ function extractBodyFacts( baseUrls: string[], wrappers: WrapperRegistry, stores: StoreRegistry, + handlerExprs: Map, ): void { // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a // data source emitted here would attribute ":path" to every consumer. Call @@ -488,8 +503,10 @@ function extractBodyFacts( if (!/^on[A-Z]/.test(attrName)) continue; const init = attr.getInitializer(); let handler: string | null = null; + let handlerExpr: Node | undefined; if (init !== undefined && Node.isJsxExpression(init)) { const expr = init.getExpression(); + handlerExpr = expr; // Plain references (handleDelete) and method references (this.refresh). if ( expr !== undefined && @@ -509,6 +526,13 @@ function extractBodyFacts( handler, }); } + // The handler expression is mined for effects in resolveHandlerChains (3.2); + // stored by node id so the same evId collects every inline arrow it carries. + if (handlerExpr !== undefined) { + const list = handlerExprs.get(evId); + if (list) list.push(handlerExpr); + else handlerExprs.set(evId, [handlerExpr]); + } addEdge({ from: ownerId, to: evId, kind: "handles" }); } } @@ -708,7 +732,7 @@ function stateVariableName(call: CallExpression): string | null { return null; } -/** Store registry (TRACKER step 2.4, failure mode C6). */ +/** Store registry (TRACKER step 2.4, failure mode C6; dispatch effects 3.2). */ interface StoreRegistry { /** Redux slice name β†’ its global StateNode id. */ reduxSlices: Map; @@ -716,6 +740,14 @@ interface StoreRegistry { zustandHooks: Map; /** createAsyncThunk name β†’ the data-source node ids its payload creator hits. */ thunkSources: Map; + /** + * Reducer action-creator name (a `reducers` key of a createSlice) β†’ the + * slice StateNode it mutates. Lets `dispatch(clearCart())` resolve to the + * cart slice (TRACKER step 3.2, B2 dispatch half). + */ + actionSlices: Map; + /** createAsyncThunk name β†’ the slice StateNode its extraReducers populate. */ + thunkSlices: Map; } /** @@ -737,6 +769,8 @@ function detectStores( reduxSlices: new Map(), zustandHooks: new Map(), thunkSources: new Map(), + actionSlices: new Map(), + thunkSlices: new Map(), }; const ensureDataSource = (call: Node, file: string): string | null => { @@ -788,6 +822,19 @@ function detectStores( }); } registry.reduxSlices.set(sliceName, stateId); + // Reducer keys are action-creator names: dispatch(clearCart()) writes here. + const reducers = config !== undefined ? propertyInitializer(config, "reducers") : undefined; + if (reducers !== undefined && Node.isObjectLiteralExpression(reducers)) { + for (const property of reducers.getProperties()) { + const nameNode = + Node.isPropertyAssignment(property) || Node.isMethodDeclaration(property) + ? property.getNameNode() + : Node.isShorthandPropertyAssignment(property) + ? property.getNameNode() + : undefined; + if (nameNode !== undefined) registry.actionSlices.set(nameNode.getText(), stateId); + } + } } else if (callee === "createAsyncThunk") { const payloadCreator = init.getArguments()[1]; const sources: string[] = []; @@ -835,6 +882,8 @@ function detectStores( if (nameInit === undefined || !Node.isStringLiteral(nameInit)) continue; const stateId = registry.reduxSlices.get(nameInit.getLiteralValue()); if (stateId === undefined) continue; + // dispatch(fetchUsers()) writes this slice (via the thunk's fulfilled case). + registry.thunkSlices.set(thunkName, stateId); for (const dsId of sources) { addEdge({ from: dsId, to: stateId, kind: "writes-state" }); } @@ -857,6 +906,7 @@ function scanClassComponents( localeTable: LocaleTable | null, pendingInstances: PendingInstance[], stores: StoreRegistry, + handlerExprs: Map, ): void { for (const cls of sourceFile.getClasses()) { const name = cls.getName(); @@ -898,7 +948,7 @@ function scanClassComponents( }); collectInstanceSites(render, id, file, pendingInstances); // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. - extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores); + extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs); extractClassState(cls, name, id, file, nodes, addEdge); } } @@ -1309,22 +1359,42 @@ function resolvePropFlow( } /** - * Handler resolution through props (TRACKER step 2.3, failure mode B1). + * Handler resolution + action effects (TRACKER steps 2.3 & 3.2; B1, B3, B2). * - * + + + ); +} diff --git a/eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx b/eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx new file mode 100644 index 0000000..7a47786 --- /dev/null +++ b/eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx @@ -0,0 +1,28 @@ +import { useNavigate } from "react-router-dom"; + +interface User { + id: string; + name: string; +} + +export function UsersList() { + const navigate = useNavigate(); + const users: User[] = []; + + const refresh = () => fetch("/api/users"); + + return ( +
+

All users

+ +
    + {users.map((user) => ( +
  • + {/* Navigate into the detail page β€” the forward edge of the loop. */} + +
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/b6-cyclic-journeys/golden.json b/eval/fixtures/b6-cyclic-journeys/golden.json new file mode 100644 index 0000000..7b7daf3 --- /dev/null +++ b/eval/fixtures/b6-cyclic-journeys/golden.json @@ -0,0 +1,29 @@ +{ + "failureMode": "B6", + "note": "Journey query with lazy expansion over a list ↔ detail loop. From /users a click navigates to /users/:userId, whose 'Back' click navigates to /users β€” a cycle. journeys() expands paths at query time with a per-path visited-set, so the loop yields a finite path that ends 'cycle' rather than hanging. Terminal paths end at a fetch. A depth-n request on the cyclic graph must terminate quickly (asserted in unit tests).", + "expect": { + "components": [ + { "name": "UsersList", "instances": 0 }, + { "name": "UserDetail", "instances": 0 } + ], + "routes": [ + { "path": "/users", "component": "UsersList" }, + { "path": "/users/:userId", "component": "UserDetail" } + ], + "journeys": [ + { + "start": "/users", + "depth": 3, + "expect": [ + { "pages": ["/users", "/users/:userId", "/users"], "end": "cycle" }, + { "pages": ["/users", "/users/:userId"], "end": "terminal" }, + { "pages": ["/users"], "end": "terminal" } + ] + } + ], + "queries": [ + { "terms": ["All users"], "status": "ok", "top": "UsersList" }, + { "terms": ["User profile"], "status": "ok", "top": "UserDetail" } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index f499edb..c23e757 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -1,6 +1,7 @@ /** Run one fixture's golden checks against its scanned graph. */ import { + journeys, type LineageGraph, matchComponentsByText, traceLineage, @@ -165,6 +166,32 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): ); } + for (const expected of golden.expect.journeys ?? []) { + for (const want of expected.expect) { + const id = `journey:${expected.start}=>${want.pages.join(">")}${want.end !== undefined ? `[${want.end}]` : ""}`; + const result = journeys(graph, expected.start, { depth: expected.depth ?? 3 }); + const found = (result.candidates[0]?.value ?? []).find((path) => { + const pages = path.steps.filter((s) => s.kind === "page").map((s) => s.label); + return ( + pages.length === want.pages.length && + pages.every((p, i) => p === want.pages[i]) && + (want.end === undefined || path.end === want.end) + ); + }); + finalize( + "journeys", + id, + result.status === "ok" && found !== undefined, + expected.expectedFail, + result.status !== "ok" + ? `journeys(${expected.start}) returned ${result.status}` + : found === undefined + ? `no path visiting [${want.pages.join(" β†’ ")}]${want.end !== undefined ? ` ending ${want.end}` : ""}` + : undefined, + ); + } + } + for (const query of golden.expect.queries ?? []) { const id = `query:${query.terms.join("+")}`; const result = matchComponentsByText(graph, query.terms); diff --git a/eval/src/golden.ts b/eval/src/golden.ts index 7cbc479..f46865a 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -69,6 +69,19 @@ export interface GoldenEffect { expectedFail?: string; } +export interface GoldenJourney { + /** Entry point: a route path ("/users") or a component name. */ + start: string; + depth?: number; + /** + * Journey paths that must appear, identified by the ordered page labels + * (route paths) they visit. `end` asserts how the path terminates β€” + * "cycle" is the B6 guarantee that a list ↔ detail loop stays finite. + */ + expect: Array<{ pages: string[]; end?: "terminal" | "cycle" | "depth-limit" }>; + expectedFail?: string; +} + export interface Golden { failureMode: string; note?: string; @@ -90,6 +103,8 @@ export interface Golden { forbiddenRoutes?: string[]; /** Action effects (step 3.2): event β†’ navigates-to / triggers / writes-state. */ effects?: GoldenEffect[]; + /** Journey paths (step 3.3): page β†’ event β†’ effect β†’ page, lazily expanded. */ + journeys?: GoldenJourney[]; }; } @@ -98,7 +113,7 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass"; export interface CheckResult { /** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */ id: string; - kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects"; + kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects" | "journeys"; status: CheckStatus; detail?: string; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 68aa15e..52a5f42 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,8 @@ import { type Candidate, collectGraphMeta, type ComponentMatch, + type JourneyPath, + journeys, type LineageGraph, loadGraph as loadGraphFile, matchComponentsByText, @@ -121,6 +123,50 @@ program console.log(` confidence: ${result.candidates[0].confidence.level}`); }); +program + .command("journeys") + .description("Trace user-journey paths from a page or component (click β†’ navigate β†’ click…)") + .argument("", "route path (/users/:id), component name, or instance id") + .option("-g, --graph ", "graph file", "coderadar.graph.json") + .option("-d, --depth ", "max navigation levels per path", "3") + .action((start: string, opts: { graph: string; depth: string }) => { + const graph = loadGraph(opts.graph); + const depth = Number.parseInt(opts.depth, 10); + const result = journeys(graph, start, { depth: Number.isNaN(depth) ? 3 : depth }); + if (result.status === "declined" || result.candidates[0] === undefined) { + console.error(`No journeys from ${start} (${result.declineReason ?? "no result"}).`); + process.exitCode = 1; + return; + } + const paths = result.candidates[0].value; + if (paths.length === 0) { + console.log(`No user actions found on ${start}.`); + return; + } + console.log(`${paths.length} journey path(s) from ${start}:\n`); + for (const path of paths) printJourneyPath(path); + }); + +const STEP_ARROW: Record = { + page: "β–Έ", + event: "β€’", + navigate: "β†’", + fetch: "β‡’", + "state-write": "✎", +}; + +function printJourneyPath(path: JourneyPath): void { + const parts = path.steps.map((step) => { + const glyph = STEP_ARROW[step.kind] ?? "Β·"; + const cond = step.condition !== undefined ? ` [${step.condition.expression}]` : ""; + const label = + step.kind === "event" ? `${step.label}()` : step.kind === "fetch" ? `fetch ${step.label}` : step.label; + return `${glyph} ${label}${cond}`; + }); + const tag = path.end === "cycle" ? " ↩ cycle" : path.end === "depth-limit" ? " … (depth limit)" : ""; + console.log(` ${parts.join(" ")}${tag}`); +} + function printMatchCandidate(candidate: Candidate): void { const match = candidate.value; console.log( diff --git a/packages/core/src/journeys.test.ts b/packages/core/src/journeys.test.ts new file mode 100644 index 0000000..554c276 --- /dev/null +++ b/packages/core/src/journeys.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { journeys } from "./query.js"; +import type { + ComponentNode, + DataSourceNode, + EdgeKind, + EventNode, + LineageEdge, + LineageGraph, + LineageNode, + RouteNode, +} from "./types.js"; +import { instanceId, nodeId } from "./types.js"; + +const loc = (file: string, line = 1) => ({ file, line, endLine: line }); + +function component(name: string): ComponentNode { + return { + id: nodeId("component", `${name}.tsx`, name), + kind: "component", + name, + loc: loc(`${name}.tsx`), + exportName: name, + props: [], + renderedText: [], + rendersComponents: [], + }; +} + +function route(path: string): RouteNode { + return { + id: nodeId("route", "routes.tsx", path), + kind: "route", + name: path, + loc: loc("routes.tsx"), + path, + router: "react-router", + layout: null, + guards: [], + }; +} + +function event(owner: string, name: string, handler: string): EventNode { + return { + id: nodeId("event", `${owner}.tsx`, `${owner}.${name}:${handler}`), + kind: "event", + name, + loc: loc(`${owner}.tsx`), + event: name, + handler, + }; +} + +function dataSource(endpoint: string): DataSourceNode { + return { + id: nodeId("data-source", "api.ts", `fetch:${endpoint}`), + kind: "data-source", + name: endpoint, + loc: loc("api.ts"), + sourceKind: "fetch", + method: "GET", + endpoint, + raw: endpoint, + resolved: "full", + }; +} + +const edge = (from: string, to: string, kind: EdgeKind, extra: Partial = {}): LineageEdge => ({ + from, + to, + kind, + ...extra, +}); + +function graphOf(nodes: LineageNode[], edges: LineageEdge[]): LineageGraph { + return { + version: 2, + root: "/app", + generatedAt: "2026-01-01T00:00:00.000Z", + generator: "test", + nodes, + edges, + }; +} + +/** Two pages that navigate to each other β€” the B6 list ↔ detail loop. */ +function cyclicGraph(): LineageGraph { + const a = component("PageA"); + const b = component("PageB"); + const ra = route("/a"); + const rb = route("/b"); + const goB = event("PageA", "onClick", "goB"); + const fetchA = event("PageA", "onClick", "load"); + const goA = event("PageB", "onClick", "goA"); + const ds = dataSource("/api/a"); + return graphOf( + [a, b, ra, rb, goB, fetchA, goA, ds], + [ + edge(ra.id, a.id, "routes-to"), + edge(rb.id, b.id, "routes-to"), + edge(a.id, goB.id, "handles"), + edge(a.id, fetchA.id, "handles"), + edge(b.id, goA.id, "handles"), + edge(goB.id, rb.id, "navigates-to"), + edge(goA.id, ra.id, "navigates-to"), + edge(fetchA.id, ds.id, "triggers"), + ], + ); +} + +const pagesOf = (steps: { kind: string; label: string }[]): string[] => + steps.filter((s) => s.kind === "page").map((s) => s.label); + +describe("journeys() β€” lazy expansion (TRACKER 3.3, B5/B6)", () => { + it("expands a page's events into navigate and fetch paths", () => { + const result = journeys(cyclicGraph(), "/a", { depth: 3 }); + expect(result.status).toBe("ok"); + const paths = result.candidates[0]?.value ?? []; + const signatures = paths.map((p) => `${pagesOf(p.steps).join(">")}|${p.end}`); + expect(signatures).toContain("/a|terminal"); // /a β†’ onClick β†’ fetch /api/a + expect(signatures).toContain("/a>/b>/a|cycle"); // list ↔ detail loop, finite + }); + + it("closes a list ↔ detail loop as a finite 'cycle' path instead of looping", () => { + const paths = journeys(cyclicGraph(), "/a", { depth: 3 }).candidates[0]?.value ?? []; + const loopPath = paths.find((p) => p.end === "cycle"); + expect(loopPath).toBeDefined(); + expect(pagesOf(loopPath!.steps)).toEqual(["/a", "/b", "/a"]); + }); + + it("terminates on a cyclic graph even at large depth (never hangs)", () => { + const started = Date.now(); + const paths = journeys(cyclicGraph(), "/a", { depth: 50 }).candidates[0]?.value ?? []; + expect(Date.now() - started).toBeLessThan(1000); + // Every path ends explicitly; no path is left dangling mid-expansion. + expect(paths.every((p) => ["terminal", "cycle", "depth-limit"].includes(p.end))).toBe(true); + }); + + it("caps a non-cyclic chain at the requested depth with a depth-limit end", () => { + const [a, b, c, d] = ["A", "B", "C", "D"].map(component); + const [ra, rb, rc, rd] = ["/a", "/b", "/c", "/d"].map(route); + const eAB = event("A", "onClick", "toB"); + const eBC = event("B", "onClick", "toC"); + const eCD = event("C", "onClick", "toD"); + const g = graphOf( + [a, b, c, d, ra, rb, rc, rd, eAB, eBC, eCD], + [ + edge(ra.id, a.id, "routes-to"), + edge(rb.id, b.id, "routes-to"), + edge(rc.id, c.id, "routes-to"), + edge(rd.id, d.id, "routes-to"), + edge(a.id, eAB.id, "handles"), + edge(b.id, eBC.id, "handles"), + edge(c.id, eCD.id, "handles"), + edge(eAB.id, rb.id, "navigates-to"), + edge(eBC.id, rc.id, "navigates-to"), + edge(eCD.id, rd.id, "navigates-to"), + ], + ); + const paths = journeys(g, "/a", { depth: 3 }).candidates[0]?.value ?? []; + const deepest = paths.find((p) => p.end === "depth-limit"); + expect(deepest).toBeDefined(); + expect(pagesOf(deepest!.steps)).toEqual(["/a", "/b", "/c"]); // stops before /d + }); + + it("resolves the start from a route path, a component name, or an instance id", () => { + const g = cyclicGraph(); + const inst = { + id: instanceId("Host.tsx", 4, "PageA"), + kind: "instance" as const, + name: "PageA", + loc: loc("Host.tsx", 4), + definitionId: nodeId("component", "PageA.tsx", "PageA"), + parentInstanceId: null, + staticProps: {}, + }; + expect(journeys(g, "/a").status).toBe("ok"); + expect(journeys(g, "PageA").status).toBe("ok"); + expect(journeys(graphOf([...g.nodes, inst], g.edges), inst.id).status).toBe("ok"); + expect(journeys(g, "NoSuchThing").status).toBe("declined"); + }); + + it("carries an edge condition onto the journey step it gates", () => { + const g = cyclicGraph(); + const gated = g.edges.map((e) => + e.kind === "navigates-to" && e.from.includes("goB") + ? { ...e, condition: { kind: "role" as const, expression: "isAdmin" } } + : e, + ); + const paths = journeys(graphOf(g.nodes, gated), "/a", { depth: 3 }).candidates[0]?.value ?? []; + const navStep = paths + .flatMap((p) => p.steps) + .find((s) => s.kind === "navigate" && s.condition !== undefined); + expect(navStep?.condition?.expression).toBe("isAdmin"); + }); +}); diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index b406037..41948c4 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -18,12 +18,15 @@ import { normalizeText, textMatches } from "./text.js"; import type { ComponentNode, DataSourceNode, + EdgeCondition, EventNode, Evidence, InstanceNode, LineageEdge, LineageGraph, LineageNode, + RouteNode, + SourceLocation, StateNode, } from "./types.js"; @@ -262,6 +265,217 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult { + const depth = options.depth ?? 3; + const maxPaths = options.maxPaths ?? 256; + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const out = new Map(); + for (const e of graph.edges) { + const list = out.get(e.from); + if (list) list.push(e); + else out.set(e.from, [e]); + } + const outEdges = (id: string): LineageEdge[] => out.get(id) ?? []; + + // Resolve the entry point to a page component + the label to show for it. + let startComponentId: string; + let startLabel: string; + const route = graph.nodes.find((n): n is RouteNode => n.kind === "route" && n.path === start); + if (route !== undefined) { + const pageEdge = outEdges(route.id).find((e) => e.kind === "routes-to"); + if (pageEdge === undefined) return declined("invalid-target"); + startComponentId = pageEdge.to; + startLabel = route.path; + } else { + const node = byId.get(start) ?? graph.nodes.find((n) => n.kind === "component" && n.name === start); + if (node === undefined) return declined("not-found"); + if (node.kind === "instance") { + startComponentId = node.definitionId; + startLabel = byId.get(node.definitionId)?.name ?? start; + } else if (node.kind === "component") { + startComponentId = node.id; + startLabel = node.name; + } else { + return declined("invalid-target"); + } + } + if (!byId.has(startComponentId)) return declined("not-found"); + + // The page a route lands on, indexed for the navigate β†’ route β†’ page hop. + const pageOfRoute = (routeId: string): { componentId: string; label: string } | null => { + const edge = outEdges(routeId).find((e) => e.kind === "routes-to"); + const routeNode = byId.get(routeId); + if (edge === undefined || routeNode === undefined || routeNode.kind !== "route") return null; + return { componentId: edge.to, label: routeNode.path }; + }; + + // All events on a screen: those the page component handles plus those of any + // component in its render subtree (a button living in a child component is + // still on this page). Memoized; the subtree walk is cycle-guarded. + const eventsMemo = new Map(); + const screenEvents = (componentId: string): string[] => { + const cached = eventsMemo.get(componentId); + if (cached !== undefined) return cached; + const subtree = new Set([componentId]); + const stack = [componentId]; + while (stack.length > 0) { + const cid = stack.pop() as string; + for (const edge of outEdges(cid)) { + if (edge.kind !== "renders") continue; + const inst = byId.get(edge.to); + if (inst === undefined || inst.kind !== "instance") continue; + if (!subtree.has(inst.definitionId) && byId.get(inst.definitionId)?.kind === "component") { + subtree.add(inst.definitionId); + stack.push(inst.definitionId); + } + } + } + const events: string[] = []; + for (const cid of subtree) { + for (const edge of outEdges(cid)) { + if (edge.kind === "handles" && byId.get(edge.to)?.kind === "event") events.push(edge.to); + } + } + eventsMemo.set(componentId, events); + return events; + }; + + const paths: JourneyPath[] = []; + let truncated = false; + const pageStep = (componentId: string, label: string): JourneyStep => { + const node = byId.get(componentId); + return { kind: "page", nodeId: componentId, label, ...(node ? { loc: node.loc } : {}) }; + }; + + // Depth-first expansion. `visitedPages` is copied down each branch so a page + // may appear in sibling paths but a single path never revisits one. + const expand = ( + componentId: string, + label: string, + prefix: JourneyStep[], + visitedPages: Set, + ): void => { + if (paths.length >= maxPaths) { + truncated = true; + return; + } + const pagePath = [...prefix, pageStep(componentId, label)]; + if (visitedPages.has(componentId)) { + paths.push({ steps: pagePath, end: "cycle" }); + return; + } + if (visitedPages.size + 1 >= depth) { + // One more page would exceed the depth budget β€” stop here. + paths.push({ steps: pagePath, end: "depth-limit" }); + truncated = true; + return; + } + const nextVisited = new Set(visitedPages).add(componentId); + + let branched = false; + for (const eventId of screenEvents(componentId)) { + const event = byId.get(eventId); + if (event === undefined || event.kind !== "event") continue; + for (const effect of outEdges(eventId)) { + const eventStep: JourneyStep = { + kind: "event", + nodeId: eventId, + label: event.event, + loc: event.loc, + ...(effect.condition ? { condition: effect.condition } : {}), + }; + if (effect.kind === "navigates-to") { + const page = pageOfRoute(effect.to); + if (page === null) continue; + branched = true; + const navStep: JourneyStep = { + kind: "navigate", + nodeId: effect.to, + label: byId.get(effect.to)?.name ?? effect.to, + ...(byId.get(effect.to) ? { loc: byId.get(effect.to)!.loc } : {}), + ...(effect.condition ? { condition: effect.condition } : {}), + }; + expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited); + } else if (effect.kind === "triggers" || effect.kind === "writes-state") { + const target = byId.get(effect.to); + if (target === undefined) continue; + branched = true; + const leaf: JourneyStep = + target.kind === "data-source" + ? { kind: "fetch", nodeId: target.id, label: target.endpoint, loc: target.loc } + : { kind: "state-write", nodeId: target.id, label: target.name, loc: target.loc }; + if (paths.length >= maxPaths) { + truncated = true; + return; + } + paths.push({ steps: [...pagePath, eventStep, leaf], end: "terminal" }); + } + } + } + // A screen with no expandable events is itself a terminal path. + if (!branched) paths.push({ steps: pagePath, end: "terminal" }); + }; + + expand(startComponentId, startLabel, [], new Set()); + + const evidence: Evidence[] = [ + { + kind: "edge-chain", + detail: + `Expanded ${paths.length} journey path(s) from ${startLabel} to depth ${depth}` + + `${truncated ? " (truncated)" : ""}`, + loc: byId.get(startComponentId)?.loc, + }, + ]; + return ok([{ value: paths, confidence: confidenceFromScore(0.9), evidence }]); +} + function groupInstances(graph: LineageGraph): Map { const byDefinition = new Map(); for (const node of graph.nodes) { From fcff3b521f3a231e9368a5ca223bc321d0c46285 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 00:28:02 +0530 Subject: [PATCH 20/49] feat(trace): render per-instance attribution for shared components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Definition-level traces of a shared component (e.g. DataTable) surfaced no data sources, because such components own no data β€” each render site feeds them different data via props. - core: add InstanceAttribution + optional Lineage.perInstance; compute it in traceLineage by attributing each rendering parent's reachable data sources (minus the component's own) when a component is shared (>=2 parents render it). - cli: render a "per instance" block in the trace command showing each render site (Component@Parent) and its distinct endpoints. - eval: add c1-shared-datatable fixture (UsersPage -> /api/users, InvoicesPage -> /api/invoices, both rendering DataTable). Co-Authored-By: Claude Opus 4.8 --- .../c1-shared-datatable/app/DataTable.tsx | 38 ++++++ .../c1-shared-datatable/app/InvoicesPage.tsx | 26 ++++ .../c1-shared-datatable/app/UsersPage.tsx | 26 ++++ packages/cli/src/index.ts | 15 +++ packages/core/src/query.ts | 114 +++++++++++++----- 5 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 eval/fixtures/c1-shared-datatable/app/DataTable.tsx create mode 100644 eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx create mode 100644 eval/fixtures/c1-shared-datatable/app/UsersPage.tsx diff --git a/eval/fixtures/c1-shared-datatable/app/DataTable.tsx b/eval/fixtures/c1-shared-datatable/app/DataTable.tsx new file mode 100644 index 0000000..2fa7287 --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/DataTable.tsx @@ -0,0 +1,38 @@ +// A shared, presentational table. It owns no data of its own β€” every render +// site passes different rows/columns via props. This is the headline C1 case: +// a definition-level trace of DataTable finds no data sources, yet each +// instance is fed by a distinct endpoint. + +interface Column { + key: string; + header: string; +} + +export function DataTable({ + rows, + columns, +}: { + rows: Record[]; + columns: Column[]; +}) { + return ( + + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{col.header}
{String(row[col.key])}
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx b/eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx new file mode 100644 index 0000000..df71c7e --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import { DataTable } from "./DataTable"; + +export function InvoicesPage() { + const [invoices, setInvoices] = useState([]); + + useEffect(() => { + fetch("/api/invoices") + .then((res) => res.json()) + .then((data) => setInvoices(data)); + }, []); + + return ( +
+

Invoices

+ +
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/app/UsersPage.tsx b/eval/fixtures/c1-shared-datatable/app/UsersPage.tsx new file mode 100644 index 0000000..83c59bf --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/UsersPage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import { DataTable } from "./DataTable"; + +export function UsersPage() { + const [users, setUsers] = useState([]); + + useEffect(() => { + fetch("/api/users") + .then((res) => res.json()) + .then((data) => setUsers(data)); + }, []); + + return ( +
+

Users

+ +
+ ); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 20818f2..cfbc70a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -100,6 +100,21 @@ program if (lineage.via.length > 0) { console.log(` via: ${lineage.via.map((v) => v.name).join(", ")}`); } + if (lineage.perInstance !== undefined && lineage.perInstance.length > 0) { + console.log(" per instance:"); + for (const inst of lineage.perInstance) { + console.log( + ` ${lineage.component.name}@${inst.parent.name} (${inst.parent.loc.file}:${inst.parent.loc.line})`, + ); + if (inst.dataSources.length === 0) { + console.log(" (no distinct data sources)"); + continue; + } + for (const ds of inst.dataSources) { + console.log(` β†’ ${ds.method ?? "?"} ${ds.endpoint} (${ds.loc.file}:${ds.loc.line})`); + } + } + } }); function loadGraph(file: string): LineageGraph { diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index d25120e..f039bf5 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -44,6 +44,22 @@ export function matchComponentsByText(graph: LineageGraph, terms: string[]): Com return matches.sort((a, b) => b.score - a.score); } +/** + * Attribution of data for a single render site of a shared component. + * + * A component like `DataTable` often owns no data of its own β€” each parent that + * renders it passes different data via props. Statically resolving prop dataflow + * is out of scope for v0.1, so this uses the pragmatic approximation: the data + * sources reachable from the rendering `parent` that are not intrinsic to the + * shared component itself. + */ +export interface InstanceAttribution { + /** The component whose JSX renders this instance of the shared component. */ + parent: ComponentNode; + /** Data sources reachable from `parent` but not intrinsic to the shared component. */ + dataSources: DataSourceNode[]; +} + export interface Lineage { component: ComponentNode; dataSources: DataSourceNode[]; @@ -51,6 +67,20 @@ export interface Lineage { events: EventNode[]; /** Hooks and child components on the path, in discovery order. */ via: LineageNode[]; + /** + * Per-render-site attribution, present only when the component is shared + * (rendered by two or more parents). Distinguishes, e.g., + * `DataTable@UsersPage β†’ /api/users` from `DataTable@InvoicesPage β†’ /api/invoices`. + */ + perInstance?: InstanceAttribution[]; +} + +/** The reachable feeders of a component, ignoring per-instance attribution. */ +interface Reachable { + dataSources: DataSourceNode[]; + state: StateNode[]; + events: EventNode[]; + via: LineageNode[]; } /** @@ -69,36 +99,64 @@ export function traceLineage(graph: LineageGraph, componentId: string): Lineage const start = byId.get(componentId); if (start === undefined || start.kind !== "component") return null; - const lineage: Lineage = { component: start, dataSources: [], state: [], events: [], via: [] }; - const seen = new Set([componentId]); - const queue: string[] = [componentId]; - - while (queue.length > 0) { - const id = queue.shift(); - if (id === undefined) break; - for (const edge of out.get(id) ?? []) { - if (seen.has(edge.to)) continue; - seen.add(edge.to); - const node = byId.get(edge.to); - if (node === undefined) continue; - switch (node.kind) { - case "data-source": - lineage.dataSources.push(node); - break; - case "state": - lineage.state.push(node); - break; - case "event": - lineage.events.push(node); - queue.push(node.id); // events can trigger fetches/state writes - break; - case "hook": - case "component": - lineage.via.push(node); - queue.push(node.id); - break; + const walk = (rootId: string): Reachable => { + const reachable: Reachable = { dataSources: [], state: [], events: [], via: [] }; + const seen = new Set([rootId]); + const queue: string[] = [rootId]; + + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined) break; + for (const edge of out.get(id) ?? []) { + if (seen.has(edge.to)) continue; + seen.add(edge.to); + const node = byId.get(edge.to); + if (node === undefined) continue; + switch (node.kind) { + case "data-source": + reachable.dataSources.push(node); + break; + case "state": + reachable.state.push(node); + break; + case "event": + reachable.events.push(node); + queue.push(node.id); // events can trigger fetches/state writes + break; + case "hook": + case "component": + reachable.via.push(node); + queue.push(node.id); + break; + } } } + + return reachable; + }; + + const reachable = walk(componentId); + const lineage: Lineage = { component: start, ...reachable }; + + // Parents that render this component. When there are two or more, the + // component is shared and a single definition-level trace hides which data + // reaches which render site β€” so attribute data per parent. + const parents: ComponentNode[] = []; + for (const edge of graph.edges) { + if (edge.kind !== "renders" || edge.to !== componentId) continue; + const parent = byId.get(edge.from); + if (parent !== undefined && parent.kind === "component") parents.push(parent); + } + + if (parents.length >= 2) { + const ownSourceIds = new Set(reachable.dataSources.map((d) => d.id)); + const perInstance = parents + .map((parent) => ({ + parent, + dataSources: walk(parent.id).dataSources.filter((d) => !ownSourceIds.has(d.id)), + })) + .sort((a, b) => a.parent.name.localeCompare(b.parent.name)); + lineage.perInstance = perInstance; } return lineage; From 3e443ab36dee57193f1e5a30d5bcc2d2fc29cfa3 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 00:38:16 +0530 Subject: [PATCH 21/49] chore(cli): repackage as single publishable `ui-lineage` bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the CLI package into one self-contained npm package named `ui-lineage` (the @coderadar scope is owned by another account and unusable). The internal @coderadar/core and @coderadar/parser-react workspace packages are bundled into the output via tsup, so consumers depend only on `ui-lineage` plus its three external deps (ts-morph, yaml, commander). - packages/cli β†’ name "ui-lineage"; bin `ui-lineage`; library entry src/lib.ts re-exports the core query API + the React scanner (main/exports/types β†’ lib). - tsup config: bundle @coderadar/* into JS *and* d.ts (dts.resolve), keep the heavy deps external; preserves the CLI shebang; emits index (bin) + lib. - Mark @coderadar/core and @coderadar/parser-react private (their code ships inside ui-lineage; also prevents accidental publish to the taken scope). - Rebrand CLI program name / help / default graph filename to ui-lineage. - Add package README (npm listing) and prepublishOnly; ignore *.graph.json. Verified: pnpm -r build/typecheck/test + eval all green (185 checks, precision/ recall 1.000). npm pack β†’ npm install of the tarball into a clean project works: the `ui-lineage` bin runs and `import { scanReact, journeys } from "ui-lineage"` resolves with no @coderadar imports in the bundle. Not published β€” packaging only. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + packages/cli/README.md | 67 ++++ packages/cli/package.json | 46 ++- packages/cli/src/index.ts | 14 +- packages/cli/src/lib.ts | 11 + packages/cli/tsup.config.ts | 20 + packages/core/package.json | 3 +- packages/parser-react/package.json | 3 +- pnpm-lock.yaml | 563 ++++++++++++++++++++++++++++- 9 files changed, 708 insertions(+), 22 deletions(-) create mode 100644 packages/cli/README.md create mode 100644 packages/cli/src/lib.ts create mode 100644 packages/cli/tsup.config.ts diff --git a/.gitignore b/.gitignore index 4c08c23..b709cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ vite.config.ts.timestamp-* # Eval outputs (scorecard is regenerated every run; history is recorded deliberately) eval/scorecard.json + +# ui-lineage scanned graphs +*.graph.json diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..2738278 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,67 @@ +# ui-lineage + +**Map UI components to their data sources and user journeys** β€” trace any screenshot or ticket back to the code, APIs, state, and navigation behind it. Deterministic static analysis for React/TSX. No LLM, no network calls. + +`ui-lineage` scans a React codebase into a **lineage graph** and lets you query it three ways: + +- **match** β€” text seen on screen β†’ the component(s) that render it +- **trace** β€” a component β†’ every API, state slice, and event that feeds it (attributed *per instance*, so a shared `` on the Users page reports `/api/users` while the same component on Invoices reports `/api/invoices`) +- **journeys** β€” a page β†’ the user-action paths leading out of it (click β†’ navigate β†’ click…), lazily expanded and cycle-safe + +## Install + +```bash +npm install -g ui-lineage # CLI +npm install ui-lineage # library +``` + +Requires Node β‰₯ 20. + +## CLI + +```bash +# 1. Scan a React app into a graph +ui-lineage scan ./src -o app.graph.json + +# 2. Find a component from on-screen text +ui-lineage find "All invoices" -g app.graph.json + +# 3. Trace a component (or an instance id) to its data +ui-lineage trace InvoicesPage -g app.graph.json + +# 4. Walk the user journeys from a page or route +ui-lineage journeys /users -g app.graph.json +``` + +`journeys` output reads left-to-right, with `↩ cycle` where a list ⇄ detail loop closes: + +``` + β–Έ /users β€’ onClick() β†’ /users/:userId β–Έ /users/:userId β€’ onClick() β†’ /users β–Έ /users ↩ cycle + β–Έ /users β€’ onClick() β‡’ fetch /api/users +``` + +## Library + +```ts +import { scanReact, resolveHookEdges, journeys, traceLineage, matchComponentsByText } from "ui-lineage"; + +const graph = resolveHookEdges(scanReact({ root: "./src" })); + +const match = matchComponentsByText(graph, ["All invoices"]); +const lineage = traceLineage(graph, match.candidates[0].value.component.id); +const paths = journeys(graph, "/users", { depth: 3 }); +``` + +Every query returns a `QueryResult` envelope β€” ranked `candidates` with evidence and confidence, or an honest `ambiguous` / `declined`. + +## What it understands + +Endpoints (constants, templates, API wrappers, react-query/SWR), i18n text, cross-file instance trees and per-instance prop-flow, Redux/Zustand stores, portals/modals/toasts, React Router & Next.js routes, and action effects (navigate / fetch / dispatch / setState) mined from event handlers. + +## Status + +Early (v0.1). The matching engine, screenshot adapter, and MCP server are on the roadmap. Output is deterministic and language-agnostic (plain JSON graph), designed to feed AI agents as a context provider β€” not to be one. + +## License + +MIT diff --git a/packages/cli/package.json b/packages/cli/package.json index 1aeae1f..ebe02dc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,26 +1,54 @@ { - "name": "@coderadar/cli", + "name": "ui-lineage", "version": "0.1.0", - "description": "CodeRadar CLI β€” scan a React codebase into a lineage graph and query it.", + "description": "Map UI components to their data sources and user journeys β€” trace any screenshot back to the code, APIs, state, and navigation behind it. Deterministic static analysis for React/TSX.", "license": "MIT", "type": "module", "bin": { - "coderadar": "dist/index.js" + "ui-lineage": "dist/index.js" + }, + "main": "dist/lib.js", + "module": "dist/lib.js", + "types": "dist/lib.d.ts", + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "default": "./dist/lib.js" + } }, "files": [ - "dist" + "dist", + "README.md" + ], + "keywords": [ + "react", + "static-analysis", + "data-lineage", + "data-flow", + "ast", + "ts-morph", + "user-journeys", + "component-graph", + "cli" ], + "engines": { + "node": ">=20" + }, "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "build": "tsup", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" }, "dependencies": { - "@coderadar/core": "workspace:*", - "@coderadar/parser-react": "workspace:*", - "commander": "^13.0.0" + "commander": "^13.0.0", + "ts-morph": "^24.0.0", + "yaml": "^2.9.0" }, "devDependencies": { + "@coderadar/core": "workspace:*", + "@coderadar/parser-react": "workspace:*", "@types/node": "^22.20.1", + "tsup": "^8.5.1", "typescript": "^5.7.0" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 52a5f42..f58426f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,9 +20,9 @@ import { Command } from "commander"; const program = new Command(); program - .name("coderadar") + .name("ui-lineage") .description( - "Map UI components to their data sources β€” trace any screenshot back to the code and data behind it.", + "Map UI components to their data sources and user journeys β€” trace any screenshot back to the code, APIs, state, and navigation behind it.", ) .version("0.1.0"); @@ -30,7 +30,7 @@ program .command("scan") .description("Scan a React codebase and emit a lineage graph JSON") .argument("", "directory to scan") - .option("-o, --out ", "output file", "coderadar.graph.json") + .option("-o, --out ", "output file", "ui-lineage.graph.json") .action((dir: string, opts: { out: string }) => { const meta = collectGraphMeta(path.resolve(dir)); const graph = { ...resolveHookEdges(scanReact({ root: dir })), meta }; @@ -56,7 +56,7 @@ program .command("find") .description("Find components by text visible on screen (e.g. read off a screenshot)") .argument("", "text fragments seen in the UI") - .option("-g, --graph ", "graph file", "coderadar.graph.json") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") .action((terms: string[], opts: { graph: string }) => { const graph = loadGraph(opts.graph); const result = matchComponentsByText(graph, terms); @@ -76,7 +76,7 @@ program .command("trace") .description("Trace a component to every data source, state, and event that feeds it") .argument("", "component name, definition id, or instance id") - .option("-g, --graph ", "graph file", "coderadar.graph.json") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") .action((component: string, opts: { graph: string }) => { const graph = loadGraph(opts.graph); const node = @@ -127,7 +127,7 @@ program .command("journeys") .description("Trace user-journey paths from a page or component (click β†’ navigate β†’ click…)") .argument("", "route path (/users/:id), component name, or instance id") - .option("-g, --graph ", "graph file", "coderadar.graph.json") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") .option("-d, --depth ", "max navigation levels per path", "3") .action((start: string, opts: { graph: string; depth: string }) => { const graph = loadGraph(opts.graph); @@ -181,7 +181,7 @@ function printMatchCandidate(candidate: Candidate): void { function loadGraph(file: string): LineageGraph { if (!fs.existsSync(file)) { - console.error(`Graph file not found: ${file} β€” run \`coderadar scan \` first.`); + console.error(`Graph file not found: ${file} β€” run \`ui-lineage scan \` first.`); process.exit(1); } try { diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts new file mode 100644 index 0000000..c9309f1 --- /dev/null +++ b/packages/cli/src/lib.ts @@ -0,0 +1,11 @@ +/** + * ui-lineage β€” public library API. + * + * One import gives you the whole toolkit: the React/TSX scanner plus the graph + * query layer (match, per-instance lineage, journeys). The internal monorepo + * packages are bundled in at build time, so consumers depend only on `ui-lineage`. + * + * import { scanReact, resolveHookEdges, journeys, traceLineage } from "ui-lineage"; + */ +export * from "@coderadar/core"; +export { resolveHookEdges, scanReact, type ScanOptions } from "@coderadar/parser-react"; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..3ed16da --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +// ui-lineage ships as a single self-contained package: the internal @coderadar/* +// workspace packages are bundled into the output, while the heavy third-party +// deps (ts-morph, yaml, commander) stay external and install normally. +export default defineConfig({ + entry: { + index: "src/index.ts", // CLI bin (keeps its #!/usr/bin/env node shebang) + lib: "src/lib.ts", // library entry + }, + format: ["esm"], + target: "node20", + // Inline the workspace packages' TYPES too, so the published .d.ts has no + // dangling references to the unpublished @coderadar/* internals. + dts: { resolve: true }, + clean: true, + sourcemap: true, + noExternal: [/^@coderadar\//], + external: ["ts-morph", "yaml", "commander"], +}); diff --git a/packages/core/package.json b/packages/core/package.json index da0344a..1a9060b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,8 @@ { "name": "@coderadar/core", "version": "0.1.0", - "description": "CodeRadar lineage graph schema β€” the language-agnostic contract every parser emits and every agent consumes.", + "private": true, + "description": "CodeRadar lineage graph schema β€” the language-agnostic contract every parser emits and every agent consumes. Bundled into the published `ui-lineage` package.", "license": "MIT", "type": "module", "main": "dist/index.js", diff --git a/packages/parser-react/package.json b/packages/parser-react/package.json index 5fc9ca0..c2f2e2a 100644 --- a/packages/parser-react/package.json +++ b/packages/parser-react/package.json @@ -1,7 +1,8 @@ { "name": "@coderadar/parser-react", "version": "0.1.0", - "description": "React/TSX parser for CodeRadar β€” extracts components, hooks, data sources, state, and events into a lineage graph.", + "private": true, + "description": "React/TSX parser for CodeRadar β€” extracts components, hooks, data sources, state, and events into a lineage graph. Bundled into the published `ui-lineage` package.", "license": "MIT", "type": "module", "main": "dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ec249..fb26991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,19 +26,28 @@ importers: packages/cli: dependencies: + commander: + specifier: ^13.0.0 + version: 13.1.0 + ts-morph: + specifier: ^24.0.0 + version: 24.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: '@coderadar/core': specifier: workspace:* version: link:../core '@coderadar/parser-react': specifier: workspace:* version: link:../parser-react - commander: - specifier: ^13.0.0 - version: 13.1.0 - devDependencies: '@types/node': specifier: ^22.20.1 version: 22.20.1 + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.17)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -82,165 +91,331 @@ importers: packages: + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -426,6 +601,14 @@ packages: '@vitest/utils@3.2.7': resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -444,6 +627,12 @@ packages: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -456,6 +645,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -467,6 +660,17 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -483,6 +687,11 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -504,6 +713,9 @@ packages: picomatch: optional: true + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -513,6 +725,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -521,6 +737,17 @@ packages: engines: {node: '>=6'} hasBin: true + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -543,9 +770,15 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -555,6 +788,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -576,10 +813,43 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss@8.5.17: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -596,6 +866,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -605,6 +879,18 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -627,6 +913,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-json-schema-generator@2.9.0: resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} engines: {node: '>=22.0.0'} @@ -638,11 +931,33 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -731,86 +1046,176 @@ packages: snapshots: + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -949,6 +1354,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + acorn@8.17.0: {} + + any-promise@1.3.0: {} + assertion-error@2.0.1: {} balanced-match@1.0.2: {} @@ -963,6 +1372,11 @@ snapshots: dependencies: balanced-match: 4.0.4 + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + cac@6.7.14: {} chai@5.3.3: @@ -975,12 +1389,22 @@ snapshots: check-error@2.1.3: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + code-block-writer@13.0.3: {} commander@13.1.0: {} commander@14.0.3: {} + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -989,6 +1413,35 @@ snapshots: es-module-lexer@1.7.0: {} + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -1028,6 +1481,12 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + fsevents@2.3.3: optional: true @@ -1037,10 +1496,18 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + joycon@3.1.1: {} + js-tokens@9.0.1: {} json5@2.2.3: {} + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + loupe@3.2.1: {} lru-cache@11.5.2: {} @@ -1059,12 +1526,27 @@ snapshots: minipass@7.1.3: {} + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.15: {} normalize-path@3.0.0: {} + object-assign@4.1.1: {} + path-browserify@1.0.1: {} path-scurry@2.0.2: @@ -1080,12 +1562,31 @@ snapshots: picomatch@4.0.5: {} + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.17)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.17 + yaml: 2.9.0 + postcss@8.5.17: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 @@ -1123,6 +1624,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -1131,6 +1634,24 @@ snapshots: dependencies: js-tokens: 9.0.1 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1146,6 +1667,10 @@ snapshots: tinyspy@4.0.4: {} + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + ts-json-schema-generator@2.9.0: dependencies: '@types/json-schema': 7.0.15 @@ -1164,8 +1689,38 @@ snapshots: tslib@2.8.1: {} + tsup@8.5.1(postcss@8.5.17)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.17)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.17 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + typescript@5.9.3: {} + ufo@1.6.4: {} + undici-types@6.21.0: {} vite-node@3.2.4(@types/node@22.20.1)(yaml@2.9.0): From 9a9879e4e0b17b69907acb79fbff68f610502859 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 01:25:44 +0530 Subject: [PATCH 22/49] feat(events): form-library & non-JSX event adapters (B7/B8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend event extraction beyond JSX on* props (TRACKER step 3.4): - react-hook-form:
unwraps to the real submit handler (the wrapped argument), event sourced "form". - Formik: onSubmit on a / tag treated as a submit handler. - addEventListener("keydown", onKey) inside an effect β†’ event sourced "effect". - Hotkey hooks (useHotkeys/useKeyboardShortcut/…) β†’ event sourced "hotkey", keyed by the shortcut string. - Unresolvable event type β†’ event flagged "unscanned-events", never dropped. All routes share one registerEvent helper that queues the handler for the existing effect-mining pass, so form/effect/hotkey handlers get the same navigate/fetch/dispatch/setState effect edges as JSX handlers. Schema: EventNode gains optional `source` ("jsx"|"form"|"effect"|"hotkey"); JSON schema regenerated. Fixtures b8-react-hook-form (POST /api/signup via handleSubmit) and b7-effect-listeners (keydown + ctrl+s β†’ /api/save) green. 87 parser + 29 core tests pass; eval green (precision/recall 1.000). Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 6 +- .../b7-effect-listeners/app/SaveHotkey.tsx | 8 ++ .../b7-effect-listeners/app/ShortcutBar.tsx | 14 ++ eval/fixtures/b7-effect-listeners/golden.json | 15 +++ .../b8-react-hook-form/app/SignupForm.tsx | 21 +++ eval/fixtures/b8-react-hook-form/golden.json | 11 ++ packages/core/src/types.ts | 9 +- packages/parser-react/src/effects.test.ts | 36 ++++++ packages/parser-react/src/scan.ts | 122 +++++++++++++----- schemas/lineage-graph.schema.json | 12 +- 10 files changed, 220 insertions(+), 34 deletions(-) create mode 100644 eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx create mode 100644 eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx create mode 100644 eval/fixtures/b7-effect-listeners/golden.json create mode 100644 eval/fixtures/b8-react-hook-form/app/SignupForm.tsx create mode 100644 eval/fixtures/b8-react-hook-form/golden.json diff --git a/TRACKER.md b/TRACKER.md index 62eb6f0..8965e4d 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 3 β€” Journey graph -- **Next step:** 3.4 β€” Form libraries & non-JSX events -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.3 +- **Next step:** 3.5 β€” Flag / role conditions (closes Gate 3) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.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) ## What CodeRadar is @@ -204,7 +204,7 @@ The heart of the project. C1 and B1 live here. - Returns `QueryResult`. **Accept:** fixture `b6-cyclic-journeys` (list ↔ detail loop): 3-level golden paths exact, terminates < 1 s; depth-n request on a cyclic graph never hangs. -### [ ] 3.4 Form libraries & non-JSX events +### [x] 3.4 Form libraries & non-JSX events **Failure modes:** B7, B8 **Build:** react-hook-form / Formik adapters (`handleSubmit(onSubmit)` β†’ real handler); `addEventListener` in `useEffect` β†’ EventNode (`source: "effect"`); adapter list for hotkey libs. Unknown patterns β†’ file-level `flags: ["unscanned-events"]`. **Accept:** fixtures `b8-react-hook-form`, `b7-effect-listeners` green. diff --git a/eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx b/eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx new file mode 100644 index 0000000..d7a736f --- /dev/null +++ b/eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx @@ -0,0 +1,8 @@ +import { useHotkeys } from "react-hotkeys-hook"; + +export function SaveHotkey() { + // Hotkey-library registration β†’ an event sourced "hotkey" keyed "ctrl+s". + useHotkeys("ctrl+s", () => fetch("/api/save", { method: "POST" })); + + return Ctrl+S to save; +} diff --git a/eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx b/eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx new file mode 100644 index 0000000..a434c0a --- /dev/null +++ b/eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +export function ShortcutBar() { + useEffect(() => { + // addEventListener inside an effect β†’ an event sourced "effect". + const onKey = (e: KeyboardEvent) => { + if (e.key === "s") fetch("/api/save", { method: "POST" }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return
Press S to save
; +} diff --git a/eval/fixtures/b7-effect-listeners/golden.json b/eval/fixtures/b7-effect-listeners/golden.json new file mode 100644 index 0000000..d0b0454 --- /dev/null +++ b/eval/fixtures/b7-effect-listeners/golden.json @@ -0,0 +1,15 @@ +{ + "failureMode": "B7", + "note": "Non-JSX events. addEventListener('keydown', onKey) inside a useEffect becomes an event sourced 'effect'; useHotkeys('ctrl+s', fn) becomes an event sourced 'hotkey'. Both handlers are mined for effects (POST /api/save), so the save action is discoverable even though there's no on* JSX prop.", + "expect": { + "components": [ + { "name": "ShortcutBar", "instances": 0 }, + { "name": "SaveHotkey", "instances": 0 } + ], + "effects": [ + { "component": "ShortcutBar", "event": "keydown", "effect": "triggers", "to": "/api/save" }, + { "component": "SaveHotkey", "event": "ctrl+s", "effect": "triggers", "to": "/api/save" } + ], + "queries": [{ "terms": ["Press S to save"], "status": "ok", "top": "ShortcutBar" }] + } +} diff --git a/eval/fixtures/b8-react-hook-form/app/SignupForm.tsx b/eval/fixtures/b8-react-hook-form/app/SignupForm.tsx new file mode 100644 index 0000000..999c07f --- /dev/null +++ b/eval/fixtures/b8-react-hook-form/app/SignupForm.tsx @@ -0,0 +1,21 @@ +import { useForm } from "react-hook-form"; + +interface FormValues { + email: string; +} + +export function SignupForm() { + const { register, handleSubmit } = useForm(); + + // The real submit handler, wrapped by handleSubmit() in the JSX below. + const onValid = (data: FormValues) => + fetch("/api/signup", { method: "POST", body: JSON.stringify(data) }); + + return ( + +

Create your account

+ + + + ); +} diff --git a/eval/fixtures/b8-react-hook-form/golden.json b/eval/fixtures/b8-react-hook-form/golden.json new file mode 100644 index 0000000..8dee21f --- /dev/null +++ b/eval/fixtures/b8-react-hook-form/golden.json @@ -0,0 +1,11 @@ +{ + "failureMode": "B8", + "note": "react-hook-form:
β€” the real submit handler is the wrapped argument, not the handleSubmit() call. The onSubmit event is sourced 'form' and its handler onValid is mined for effects (POST /api/signup).", + "expect": { + "components": [{ "name": "SignupForm", "instances": 0 }], + "effects": [ + { "component": "SignupForm", "event": "onSubmit", "effect": "triggers", "to": "/api/signup" } + ], + "queries": [{ "terms": ["Create your account"], "status": "ok", "top": "SignupForm" }] + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 800c13b..f0b7ae9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -166,10 +166,17 @@ export interface StateNode extends BaseNode { /** A user or system event a component responds to. */ export interface EventNode extends BaseNode { kind: "event"; - /** e.g. "onClick", "onSubmit", "onChange" */ + /** e.g. "onClick", "onSubmit", "onChange", or a DOM/hotkey key ("keydown", "ctrl+s"). */ event: string; /** Name of the handler function, if resolvable. */ handler: string | null; + /** + * Where the binding comes from (TRACKER step 3.4): a JSX on* prop (the + * default when absent), a form-library submit handler (react-hook-form's + * `handleSubmit`, Formik), an `addEventListener` inside an effect, or a + * hotkey-library registration (`useHotkeys`). + */ + source?: "jsx" | "form" | "effect" | "hotkey"; } /** Which routing system declared a route. */ diff --git a/packages/parser-react/src/effects.test.ts b/packages/parser-react/src/effects.test.ts index 48ba09b..f976ac7 100644 --- a/packages/parser-react/src/effects.test.ts +++ b/packages/parser-react/src/effects.test.ts @@ -80,6 +80,42 @@ describe("action effects (b3 fixture, TRACKER 3.2)", () => { }); }); +describe("form & non-JSX events (TRACKER 3.4, B7/B8)", () => { + const b8 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b8-react-hook-form/app") })); + const b7 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b7-effect-listeners/app") })); + + const eventNamed = (graph: LineageGraph, event: string): LineageNode | undefined => + graph.nodes.find((n) => n.kind === "event" && n.event === event); + + it("react-hook-form: handleSubmit(onValid) unwraps to the real submit handler", () => { + const submit = eventNamed(b8, "onSubmit"); + expect(submit?.kind === "event" ? submit.source : undefined).toBe("form"); + expect(submit?.kind === "event" ? submit.handler : undefined).toBe("onValid"); + const triggers = b8.edges.filter((e) => e.kind === "triggers" && e.from === submit?.id); + const ds = b8.nodes.find((n) => n.id === triggers[0]?.to); + expect(ds?.kind === "data-source" ? ds.endpoint : undefined).toBe("/api/signup"); + }); + + it("addEventListener in an effect becomes an event sourced 'effect'", () => { + const key = eventNamed(b7, "keydown"); + expect(key?.kind === "event" ? key.source : undefined).toBe("effect"); + const triggers = b7.edges.some( + (e) => e.kind === "triggers" && e.from === key?.id && b7.nodes.find((n) => n.id === e.to)?.kind === "data-source", + ); + expect(triggers).toBe(true); + }); + + it("a hotkey registration becomes an event keyed by its shortcut", () => { + const hotkey = eventNamed(b7, "ctrl+s"); + expect(hotkey?.kind === "event" ? hotkey.source : undefined).toBe("hotkey"); + const target = b7.edges + .filter((e) => e.kind === "triggers" && e.from === hotkey?.id) + .map((e) => b7.nodes.find((n) => n.id === e.to)) + .find((n) => n?.kind === "data-source"); + expect(target?.kind === "data-source" ? target.endpoint : undefined).toBe("/api/save"); + }); +}); + describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => { const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") })); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 4f55dde..0940e37 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -102,6 +102,16 @@ const TEXT_ATTRIBUTES = new Set([ ]); const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]); const TOAST_CALLEES = /^(toast(\.\w+)?|enqueueSnackbar|message\.(success|error|info|warning))$/; +/** Hotkey-library hooks whose (keys, handler) registration becomes an event (3.4). */ +const HOTKEY_HOOKS = new Set([ + "useHotkeys", + "useHotkey", + "useKeyboardShortcut", + "useKey", + "useKeyPress", +]); +/** Form components whose `onSubmit` prop is a real submit handler (Formik, 3.4). */ +const FORM_TAGS = new Set(["Formik", "Form"]); /** Scan a directory of React source and produce a lineage graph. */ export function scanReact(options: ScanOptions): LineageGraph { @@ -428,9 +438,63 @@ function extractBodyFacts( // sites get the real, substituted endpoint instead. const declIsWrapper = wrappers.has(declName); + // Create an EventNode + handles edge and queue its handler for effect mining. + // Shared by JSX on* props and the non-JSX bindings (forms, listeners, hotkeys). + const registerEvent = ( + eventName: string, + handlerNode: Node | undefined, + source: "jsx" | "form" | "effect" | "hotkey", + at: Node, + flags?: string[], + ): void => { + let handler: string | null = null; + if ( + handlerNode !== undefined && + (Node.isIdentifier(handlerNode) || Node.isPropertyAccessExpression(handlerNode)) + ) { + handler = handlerNode.getText(); + } + const suffix = `${handler !== null ? `:${handler}` : ""}${source !== "jsx" ? `@${source}` : ""}`; + const evId = nodeId("event", file, `${declName}.${eventName}${suffix}`); + if (!nodes.has(evId)) { + nodes.set(evId, { + id: evId, + kind: "event", + name: eventName, + loc: locOf(at, file), + event: eventName, + handler, + ...(source !== "jsx" ? { source } : {}), + ...(flags !== undefined && flags.length > 0 ? { flags } : {}), + }); + } + if (handlerNode !== undefined) { + const list = handlerExprs.get(evId); + if (list) list.push(handlerNode); + else handlerExprs.set(evId, [handlerNode]); + } + addEdge({ from: ownerId, to: evId, kind: "handles" }); + }; + for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { const callee = call.getExpression().getText(); + // Non-JSX event bindings (TRACKER 3.4): addEventListener (usually in an + // effect) and hotkey-library registrations become events; an unresolvable + // event type is flagged "unscanned-events" rather than dropped. + if (callee === "addEventListener" || callee.endsWith(".addEventListener")) { + const args = call.getArguments(); + const type = resolveStringValue(args[0], 0); + registerEvent(type ?? "unknown", args[1], "effect", call, type === null ? ["unscanned-events"] : undefined); + continue; + } + if (HOTKEY_HOOKS.has(callee)) { + const args = call.getArguments(); + const keys = resolveStringValue(args[0], 0); + registerEvent(keys ?? "unknown", args[1], "hotkey", call, keys === null ? ["unscanned-events"] : undefined); + continue; + } + // Store readers/dispatchers first β€” useSelector would otherwise fall // through to the generic per-component state handling. if (callee === "useSelector") { @@ -502,39 +566,39 @@ function extractBodyFacts( const attrName = attr.getNameNode().getText(); if (!/^on[A-Z]/.test(attrName)) continue; const init = attr.getInitializer(); - let handler: string | null = null; - let handlerExpr: Node | undefined; - if (init !== undefined && Node.isJsxExpression(init)) { - const expr = init.getExpression(); - handlerExpr = expr; - // Plain references (handleDelete) and method references (this.refresh). - if ( - expr !== undefined && - (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) - ) { - handler = expr.getText(); - } + if (init === undefined || !Node.isJsxExpression(init)) { + registerEvent(attrName, undefined, "jsx", attr); + continue; } - const evId = nodeId("event", file, `${declName}.${attrName}${handler !== null ? `:${handler}` : ""}`); - if (!nodes.has(evId)) { - nodes.set(evId, { - id: evId, - kind: "event", - name: attrName, - loc: locOf(attr, file), - event: attrName, - handler, - }); + let expr: Node | undefined = init.getExpression(); + let source: "jsx" | "form" = "jsx"; + // react-hook-form: onSubmit={handleSubmit(onValid)} β€” the real handler is + // the wrapped argument, not the handleSubmit() call itself. + if (expr !== undefined && Node.isCallExpression(expr)) { + const callee = expr.getExpression(); + const calleeName = Node.isPropertyAccessExpression(callee) ? callee.getName() : callee.getText(); + if (calleeName === "handleSubmit") { + expr = expr.getArguments()[0] ?? expr; + source = "form"; + } } - // The handler expression is mined for effects in resolveHandlerChains (3.2); - // stored by node id so the same evId collects every inline arrow it carries. - if (handlerExpr !== undefined) { - const list = handlerExprs.get(evId); - if (list) list.push(handlerExpr); - else handlerExprs.set(evId, [handlerExpr]); + // Formik: β€” the onSubmit prop is a submit handler. + if (source === "jsx" && attrName === "onSubmit" && FORM_TAGS.has(jsxTagName(attr))) { + source = "form"; } - addEdge({ from: ownerId, to: evId, kind: "handles" }); + registerEvent(attrName, expr, source, attr); + } +} + +/** The tag name of the JSX element an attribute belongs to (e.g. "Formik"). */ +function jsxTagName(attr: Node): string { + const element = attr.getFirstAncestor( + (a) => Node.isJsxOpeningElement(a) || Node.isJsxSelfClosingElement(a), + ); + if (element !== undefined && (Node.isJsxOpeningElement(element) || Node.isJsxSelfClosingElement(element))) { + return element.getTagNameNode().getText(); } + return ""; } /** `useSelector((s) => s.users.list)` β†’ the "users" slice's StateNode id. */ diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 0efd934..1167c26 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -480,7 +480,7 @@ }, "event": { "type": "string", - "description": "e.g. \"onClick\", \"onSubmit\", \"onChange\"" + "description": "e.g. \"onClick\", \"onSubmit\", \"onChange\", or a DOM/hotkey key (\"keydown\", \"ctrl+s\")." }, "handler": { "type": [ @@ -488,6 +488,16 @@ "null" ], "description": "Name of the handler function, if resolvable." + }, + "source": { + "type": "string", + "enum": [ + "jsx", + "form", + "effect", + "hotkey" + ], + "description": "Where the binding comes from (TRACKER step 3.4): a JSX on* prop (the default when absent), a form-library submit handler (react-hook-form's `handleSubmit`, Formik), an `addEventListener` inside an effect, or a hotkey-library registration (`useHotkeys`)." } }, "required": [ From c03d209d169e524717eb7cbe461ffd7cf77ea119 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 01:37:17 +0530 Subject: [PATCH 23/49] =?UTF-8?q?feat(conditions):=20flag/role=20guards=20?= =?UTF-8?q?on=20renders=20&=20handles=20edges=20(G5)=20=E2=80=94=20Gate=20?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature-flag and role guards now annotate the edges (and journey steps) they gate (TRACKER step 3.5, failure modes G5/B5): - A renders/handles edge enclosed by a flag call (isEnabled/useFlag/useFeature/…, extensible via scan option `featureFlags`) or a role check (role===, hasRole, hasPermission, can(), …) carries an EdgeCondition{kind:"flag"|"role"}. - Ternary else-branches are negated; a flag hook result used as a bare boolean (`const beta = useFlag("beta")`) is resolved back to its flag call. - journeys() surfaces the handles-edge guard on the step it gates, so a path reads "… onClick [flag isEnabled("new-billing")] β†’ navigate /billing/new". Also fix an inline-handler id collision: two inline-arrow on* handlers in one component collapsed to a single event node (dropping the second's condition and effect). Inline handlers are now disambiguated by line. Eval: new `conditions` golden check + fixture g5-feature-flag (flag-gated navigation, role-gated fetch, flag-gated child render). 92 parser + 29 core tests pass; eval green (precision/recall 1.000). **Gate 3 passes β€” Phase 3 done.** Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 10 +- .../g5-feature-flag/app/BetaBanner.tsx | 3 + .../g5-feature-flag/app/BillingPage.tsx | 29 +++++ .../g5-feature-flag/app/NewBillingPage.tsx | 7 ++ eval/fixtures/g5-feature-flag/app/flags.ts | 5 + eval/fixtures/g5-feature-flag/app/routes.tsx | 9 ++ eval/fixtures/g5-feature-flag/golden.json | 30 +++++ eval/src/checks.ts | 24 ++++ eval/src/golden.ts | 23 +++- packages/core/src/query.ts | 10 +- packages/parser-react/src/conditions.test.ts | 50 ++++++++ packages/parser-react/src/scan.ts | 113 ++++++++++++++++-- 12 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 eval/fixtures/g5-feature-flag/app/BetaBanner.tsx create mode 100644 eval/fixtures/g5-feature-flag/app/BillingPage.tsx create mode 100644 eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx create mode 100644 eval/fixtures/g5-feature-flag/app/flags.ts create mode 100644 eval/fixtures/g5-feature-flag/app/routes.tsx create mode 100644 eval/fixtures/g5-feature-flag/golden.json create mode 100644 packages/parser-react/src/conditions.test.ts diff --git a/TRACKER.md b/TRACKER.md index 8965e4d..c297727 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -4,10 +4,10 @@ ## Status -- **Current phase:** 3 β€” Journey graph -- **Next step:** 3.5 β€” Flag / role conditions (closes Gate 3) -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.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) +- **Current phase:** 4 β€” Matching engine +- **Next step:** 4.1 β€” Term matching v2 +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5 +- **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) ## What CodeRadar is @@ -209,7 +209,7 @@ The heart of the project. C1 and B1 live here. **Build:** react-hook-form / Formik adapters (`handleSubmit(onSubmit)` β†’ real handler); `addEventListener` in `useEffect` β†’ EventNode (`source: "effect"`); adapter list for hotkey libs. Unknown patterns β†’ file-level `flags: ["unscanned-events"]`. **Accept:** fixtures `b8-react-hook-form`, `b7-effect-listeners` green. -### [ ] 3.5 Flag / role conditions +### [x] 3.5 Flag / role conditions **Failure modes:** G5, B5 **Build:** feature-flag detection (configurable call names: `useFlag`, `useFeature`, `isEnabled`) and role checks in render branches β†’ `EdgeCondition{kind:"flag"|"role"}` on the enclosed `renders`/`handles` edges. **Accept:** fixture `g5-feature-flag` green: flag-gated UI's journey step carries the flag name. **Gate 3 passes.** diff --git a/eval/fixtures/g5-feature-flag/app/BetaBanner.tsx b/eval/fixtures/g5-feature-flag/app/BetaBanner.tsx new file mode 100644 index 0000000..f5afebe --- /dev/null +++ b/eval/fixtures/g5-feature-flag/app/BetaBanner.tsx @@ -0,0 +1,3 @@ +export function BetaBanner() { + return ; +} diff --git a/eval/fixtures/g5-feature-flag/app/BillingPage.tsx b/eval/fixtures/g5-feature-flag/app/BillingPage.tsx new file mode 100644 index 0000000..f68bf8c --- /dev/null +++ b/eval/fixtures/g5-feature-flag/app/BillingPage.tsx @@ -0,0 +1,29 @@ +import { useNavigate } from "react-router-dom"; + +import { BetaBanner } from "./BetaBanner"; +import { isEnabled } from "./flags"; + +export function BillingPage({ role }: { role: string }) { + const navigate = useNavigate(); + + return ( +
+

Billing

+ + {/* Flag-gated child component β†’ renders edge carries the flag condition. */} + {isEnabled("beta-banner") && } + + {/* Flag-gated action β†’ handles edge (and its journey step) carries the flag. */} + {isEnabled("new-billing") && ( + + )} + + {/* Role-gated action β†’ handles edge carries a role condition. */} + {role === "admin" && ( + + )} +
+ ); +} diff --git a/eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx b/eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx new file mode 100644 index 0000000..64f8e53 --- /dev/null +++ b/eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx @@ -0,0 +1,7 @@ +export function NewBillingPage() { + return ( +
+

New billing experience

+
+ ); +} diff --git a/eval/fixtures/g5-feature-flag/app/flags.ts b/eval/fixtures/g5-feature-flag/app/flags.ts new file mode 100644 index 0000000..9bee94f --- /dev/null +++ b/eval/fixtures/g5-feature-flag/app/flags.ts @@ -0,0 +1,5 @@ +// A tiny feature-flag helper. The scanner classifies `isEnabled(...)` guards +// as `flag` conditions (it's one of the default flag callees). +export function isEnabled(flag: string): boolean { + return flag.length > 0; +} diff --git a/eval/fixtures/g5-feature-flag/app/routes.tsx b/eval/fixtures/g5-feature-flag/app/routes.tsx new file mode 100644 index 0000000..a0d99ad --- /dev/null +++ b/eval/fixtures/g5-feature-flag/app/routes.tsx @@ -0,0 +1,9 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { BillingPage } from "./BillingPage"; +import { NewBillingPage } from "./NewBillingPage"; + +export const router = createBrowserRouter([ + { path: "/billing", element: }, + { path: "/billing/new", element: }, +]); diff --git a/eval/fixtures/g5-feature-flag/golden.json b/eval/fixtures/g5-feature-flag/golden.json new file mode 100644 index 0000000..60716f7 --- /dev/null +++ b/eval/fixtures/g5-feature-flag/golden.json @@ -0,0 +1,30 @@ +{ + "failureMode": "G5", + "note": "Feature-flag and role guards become EdgeConditions on the enclosed renders/handles edges, and journeys surface them: the flag-gated 'Try new billing' navigation and the role-gated 'Reset billing' fetch each carry their guard, so a journey step reads with the flag/role name. isEnabled(...) is a default flag callee; role === \"admin\" matches the role heuristic.", + "expect": { + "components": [ + { "name": "BillingPage", "instances": 0 }, + { "name": "NewBillingPage", "instances": 0 }, + { "name": "BetaBanner", "instances": 1 } + ], + "routes": [ + { "path": "/billing", "component": "BillingPage" }, + { "path": "/billing/new", "component": "NewBillingPage" } + ], + "conditions": [ + { "component": "BillingPage", "edge": "handles", "kind": "flag", "expression": "new-billing" }, + { "component": "BillingPage", "edge": "handles", "kind": "role", "expression": "admin" }, + { "component": "BillingPage", "edge": "renders", "kind": "flag", "expression": "beta-banner" } + ], + "journeys": [ + { + "start": "/billing", + "depth": 3, + "expect": [ + { "pages": ["/billing", "/billing/new"], "end": "terminal" }, + { "pages": ["/billing"], "end": "terminal" } + ] + } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index c23e757..5e7cf51 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -192,6 +192,30 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): } } + for (const expected of golden.expect.conditions ?? []) { + const id = `condition:${expected.component}.${expected.edge}:${expected.kind}~${expected.expression}`; + const owner = graph.nodes.find((n) => n.kind === "component" && n.name === expected.component); + const matched = graph.edges.some( + (e) => + e.kind === expected.edge && + owner !== undefined && + e.from === owner.id && + e.condition?.kind === expected.kind && + e.condition.expression.includes(expected.expression), + ); + finalize( + "conditions", + id, + matched, + expected.expectedFail, + matched + ? undefined + : owner === undefined + ? "component not found" + : `no ${expected.edge} edge from ${expected.component} with a ${expected.kind} condition containing "${expected.expression}"`, + ); + } + for (const query of golden.expect.queries ?? []) { const id = `query:${query.terms.join("+")}`; const result = matchComponentsByText(graph, query.terms); diff --git a/eval/src/golden.ts b/eval/src/golden.ts index f46865a..dd511a6 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -82,6 +82,17 @@ export interface GoldenJourney { expectedFail?: string; } +export interface GoldenCondition { + /** Component the gated edge originates from. */ + component: string; + /** Which edge kind carries the condition. */ + edge: "handles" | "renders"; + kind: "flag" | "role"; + /** Substring the condition expression must contain (e.g. "new-billing"). */ + expression: string; + expectedFail?: string; +} + export interface Golden { failureMode: string; note?: string; @@ -105,6 +116,8 @@ export interface Golden { effects?: GoldenEffect[]; /** Journey paths (step 3.3): page β†’ event β†’ effect β†’ page, lazily expanded. */ journeys?: GoldenJourney[]; + /** Flag/role conditions on renders/handles edges (step 3.5). */ + conditions?: GoldenCondition[]; }; } @@ -113,7 +126,15 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass"; export interface CheckResult { /** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */ id: string; - kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects" | "journeys"; + kind: + | "components" + | "attributions" + | "forbidden" + | "queries" + | "routes" + | "effects" + | "journeys" + | "conditions"; status: CheckStatus; detail?: string; } diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 41948c4..a433f52 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -316,10 +316,12 @@ export function journeys( const maxPaths = options.maxPaths ?? 256; const byId = new Map(graph.nodes.map((n) => [n.id, n])); const out = new Map(); + const handlesConditionByEvent = new Map(); for (const e of graph.edges) { const list = out.get(e.from); if (list) list.push(e); else out.set(e.from, [e]); + if (e.kind === "handles" && e.condition !== undefined) handlesConditionByEvent.set(e.to, e.condition); } const outEdges = (id: string): LineageEdge[] => out.get(id) ?? []; @@ -422,13 +424,17 @@ export function journeys( for (const eventId of screenEvents(componentId)) { const event = byId.get(eventId); if (event === undefined || event.kind !== "event") continue; + // A flag/role guard on the handles edge (3.5) gates the whole step; an + // effect-edge condition (rarer) refines the specific effect. + const gate = handlesConditionByEvent.get(eventId); for (const effect of outEdges(eventId)) { + const stepCondition = effect.condition ?? gate; const eventStep: JourneyStep = { kind: "event", nodeId: eventId, label: event.event, loc: event.loc, - ...(effect.condition ? { condition: effect.condition } : {}), + ...(stepCondition ? { condition: stepCondition } : {}), }; if (effect.kind === "navigates-to") { const page = pageOfRoute(effect.to); @@ -439,7 +445,7 @@ export function journeys( nodeId: effect.to, label: byId.get(effect.to)?.name ?? effect.to, ...(byId.get(effect.to) ? { loc: byId.get(effect.to)!.loc } : {}), - ...(effect.condition ? { condition: effect.condition } : {}), + ...(stepCondition ? { condition: stepCondition } : {}), }; expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited); } else if (effect.kind === "triggers" || effect.kind === "writes-state") { diff --git a/packages/parser-react/src/conditions.test.ts b/packages/parser-react/src/conditions.test.ts new file mode 100644 index 0000000..47d022c --- /dev/null +++ b/packages/parser-react/src/conditions.test.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { journeys } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const g5 = resolveHookEdges( + scanReact({ + root: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/g5-feature-flag/app", + ), + }), +); + +const conditioned = (kind: string) => + g5.edges.filter((e) => e.kind === kind && e.condition !== undefined); + +describe("flag / role conditions (g5 fixture, TRACKER 3.5)", () => { + it("gates a handles edge with the feature flag guarding the action", () => { + const flagged = conditioned("handles").find((e) => e.condition?.kind === "flag"); + expect(flagged?.condition?.expression).toContain("new-billing"); + }); + + it("classifies a role guard as a role condition", () => { + const role = conditioned("handles").find((e) => e.condition?.kind === "role"); + expect(role?.condition?.expression).toContain("admin"); + }); + + it("gates a renders edge to a flag-gated child component", () => { + const rendered = conditioned("renders").find((e) => e.condition?.kind === "flag"); + expect(rendered?.condition?.expression).toContain("beta-banner"); + }); + + it("keeps the flag- and role-gated actions on separate events (no id collision)", () => { + const events = g5.nodes.filter((n) => n.kind === "event" && n.event === "onClick"); + expect(events.length).toBe(2); + }); + + it("surfaces the guard on the journey step it gates", () => { + const paths = journeys(g5, "/billing", { depth: 3 }).candidates[0]?.value ?? []; + const conditions = paths.flatMap((p) => + p.steps.flatMap((s) => (s.condition !== undefined ? [`${s.condition.kind}:${s.condition.expression}`] : [])), + ); + expect(conditions.some((c) => c.startsWith("flag:") && c.includes("new-billing"))).toBe(true); + expect(conditions.some((c) => c.startsWith("role:") && c.includes("admin"))).toBe(true); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 0940e37..d4f624d 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { type DataSourceKind, + type EdgeCondition, instanceId, type InstanceNode, type LineageEdge, @@ -59,6 +60,13 @@ export interface ScanOptions { * ours even when the definition isn't (failure mode A5). */ designSystemPackages?: string[]; + /** + * Extra feature-flag call names beyond the defaults (useFlag, useFeature, + * isEnabled, …). A `renders`/`handles` edge gated behind one of these β€” or a + * role check β€” carries an EdgeCondition so journeys read "… [flag] step" + * (TRACKER step 3.5, failure modes G5/B5). + */ + featureFlags?: string[]; } type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression; @@ -112,12 +120,26 @@ const HOTKEY_HOOKS = new Set([ ]); /** Form components whose `onSubmit` prop is a real submit handler (Formik, 3.4). */ const FORM_TAGS = new Set(["Formik", "Form"]); +/** Default feature-flag call names classified as `flag` conditions (3.5). */ +const DEFAULT_FLAG_CALLEES = [ + "useFlag", + "useFeature", + "useFeatureFlag", + "useFlags", + "isEnabled", + "isFeatureEnabled", + "hasFeature", + "featureEnabled", +]; +/** Heuristic for role/permission guards classified as `role` conditions (3.5). */ +const ROLE_PATTERN = /\brole\b|\bisAdmin\b|\bisSuperuser\b|hasRole|hasPermission|\bcan\(|\bpermission|useRole|usePermission/i; /** Scan a directory of React source and produce a lineage graph. */ export function scanReact(options: ScanOptions): LineageGraph { const root = path.resolve(options.root); const include = options.include ?? ["**/*.tsx", "**/*.jsx", "**/*.ts"]; const baseUrls = options.baseUrls ?? []; + const flagCallees = new Set([...DEFAULT_FLAG_CALLEES, ...(options.featureFlags ?? [])]); const project = new Project({ compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ }, @@ -183,10 +205,10 @@ export function scanReact(options: ScanOptions): LineageGraph { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs); + extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs, flagCallees); } - scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs); + scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs, flagCallees); collectHocAliases(sourceFile, hocAliases); } @@ -197,6 +219,7 @@ export function scanReact(options: ScanOptions): LineageGraph { hocAliases, options.designSystemPackages ?? [], root, + flagCallees, ); resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers); // Routes first: navigate() effects (3.2) join to RouteNodes by path shape. @@ -401,6 +424,74 @@ function branchTag(node: Node, boundary: Node): { branch?: string } { return {}; } +/** + * The flag/role condition guarding a rendered instance or a wired event + * (TRACKER step 3.5). Walks the enclosing branches up to the component + * boundary; returns the nearest one that is a feature-flag call or a role + * check, so its `renders`/`handles` edge (and any journey step through it) + * carries the flag/role. Undefined when the guard is a plain condition. + */ +function edgeCondition(node: Node, flagCallees: ReadonlySet): EdgeCondition | undefined { + const boundary = node.getFirstAncestor( + (a) => + Node.isFunctionDeclaration(a) || + Node.isArrowFunction(a) || + Node.isFunctionExpression(a) || + Node.isMethodDeclaration(a), + ); + let current: Node | undefined = node; + while (current !== undefined && current !== boundary) { + const parent: Node | undefined = current.getParent(); + if (parent === undefined) break; + let test: Node | undefined; + let negate = false; + if (Node.isConditionalExpression(parent)) { + if (parent.getWhenTrue() === current) test = parent.getCondition(); + else if (parent.getWhenFalse() === current) { + test = parent.getCondition(); + negate = true; + } + } else if ( + Node.isBinaryExpression(parent) && + parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken && + parent.getRight() === current + ) { + test = parent.getLeft(); + } else if (Node.isIfStatement(parent) && parent.getThenStatement() === current) { + test = parent.getExpression(); + } + if (test !== undefined) { + const condition = classifyTest(test, flagCallees); + if (condition !== undefined) { + return negate ? { ...condition, expression: `!(${condition.expression})` } : condition; + } + } + current = parent; + } + return undefined; +} + +/** Classify a guard's test expression as a feature-flag or role condition. */ +function classifyTest(test: Node, flagCallees: ReadonlySet): EdgeCondition | undefined { + const text = test.getText(); + for (const callee of flagCallees) { + if (new RegExp(`\\b${callee}\\s*\\(`).test(text)) return { kind: "flag", expression: text }; + } + // A flag hook result used as a bare boolean: `const beta = useFlag("beta")`. + if (Node.isIdentifier(test)) { + for (const definition of test.getDefinitionNodes()) { + if (!Node.isVariableDeclaration(definition)) continue; + const init = definition.getInitializer(); + if (init === undefined || !Node.isCallExpression(init)) continue; + const callee = init.getExpression().getText(); + const name = callee.includes(".") ? (callee.split(".").pop() ?? callee) : callee; + if (flagCallees.has(name)) return { kind: "flag", expression: `${text} (${init.getText()})` }; + } + } + if (ROLE_PATTERN.test(text)) return { kind: "role", expression: text }; + return undefined; +} + function extractRenderedComponents(fn: Node): string[] { const names = new Set(); const record = (tagName: string): void => { @@ -432,6 +523,7 @@ function extractBodyFacts( wrappers: WrapperRegistry, stores: StoreRegistry, handlerExprs: Map, + flagCallees: ReadonlySet, ): void { // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a // data source emitted here would attribute ":path" to every consumer. Call @@ -454,14 +546,17 @@ function extractBodyFacts( ) { handler = handlerNode.getText(); } - const suffix = `${handler !== null ? `:${handler}` : ""}${source !== "jsx" ? `@${source}` : ""}`; + // Inline handlers (no name) at different call sites are distinct events β€” + // disambiguate by line so their conditions and effects don't collapse. + const atLoc = locOf(at, file); + const suffix = `${handler !== null ? `:${handler}` : `@${atLoc.line}`}${source !== "jsx" ? `@${source}` : ""}`; const evId = nodeId("event", file, `${declName}.${eventName}${suffix}`); if (!nodes.has(evId)) { nodes.set(evId, { id: evId, kind: "event", name: eventName, - loc: locOf(at, file), + loc: atLoc, event: eventName, handler, ...(source !== "jsx" ? { source } : {}), @@ -473,7 +568,8 @@ function extractBodyFacts( if (list) list.push(handlerNode); else handlerExprs.set(evId, [handlerNode]); } - addEdge({ from: ownerId, to: evId, kind: "handles" }); + const condition = edgeCondition(at, flagCallees); + addEdge({ from: ownerId, to: evId, kind: "handles", ...(condition ? { condition } : {}) }); }; for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { @@ -971,6 +1067,7 @@ function scanClassComponents( pendingInstances: PendingInstance[], stores: StoreRegistry, handlerExprs: Map, + flagCallees: ReadonlySet, ): void { for (const cls of sourceFile.getClasses()) { const name = cls.getName(); @@ -1012,7 +1109,7 @@ function scanClassComponents( }); collectInstanceSites(render, id, file, pendingInstances); // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. - extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs); + extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs, flagCallees); extractClassState(cls, name, id, file, nodes, addEdge); } } @@ -1164,6 +1261,7 @@ function materializeInstances( hocAliases: ReadonlyMap, designSystemPackages: string[], root: string, + flagCallees: ReadonlySet, ): Array { const definitionsByName = new Map(); for (const node of nodes.values()) { @@ -1259,7 +1357,8 @@ function materializeInstances( nodes.set(id, instance); idByIndex[index] = id; created.push(instance); - addEdge({ from: pending.ownerId, to: id, kind: "renders" }); + const condition = edgeCondition(pending.element, flagCallees); + addEdge({ from: pending.ownerId, to: id, kind: "renders", ...(condition ? { condition } : {}) }); if (!resolved.external) { addEdge({ from: id, to: resolved.definitionId, kind: "instance-of" }); } From 7e04c7062fde7a1c3896376d18b81a7026a1116c Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 01:49:39 +0530 Subject: [PATCH 24/49] feat(match): rarity-weighted, fuzzy, combination-aware term scorer (A4/A10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace matchComponentsByText with a real scorer (TRACKER step 4.1): - Rarity weighting: each term's weight is its inverse document frequency across the graph, so a lone "Save" (everywhere) ties β†’ honest `ambiguous`, while one distinctive term ("Card number") breaks the tie β†’ `ok`. - Fuzzy tokens: long tokens match within a small, length-scaled edit distance, so OCR slips ("Acount Reconcilliation") still land the right component; short common tokens stay exact so noise can't collide unrelated matches. - Combination bonus: several distinct terms co-occurring in one component outrank the same terms scattered across many. - Order-sensitive phrase matching: "Order deleted" no longer equals "Delete order" (a bag-of-tokens bug caught by the a9 fixture). Match evidence keeps its i18n-key / branch provenance. New text utils: editDistance (bounded Levenshtein), fuzzyTokenMatch, tokenize. Eval harness gains GoldenQuery.topK (the honest top-3 bar for OCR). Fixture a10-ocr-noise (misspelled distinctive text β†’ correct top-1). 36 core + 92 parser tests pass; eval green (precision/recall 1.000, match accuracy 1.000). Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 6 +- .../a10-ocr-noise/app/InvoiceDashboard.tsx | 8 + .../app/ReconciliationReport.tsx | 8 + .../a10-ocr-noise/app/SettingsPanel.tsx | 8 + .../a10-ocr-noise/app/ShipmentTracker.tsx | 8 + eval/fixtures/a10-ocr-noise/golden.json | 18 ++ eval/src/checks.ts | 12 +- eval/src/golden.ts | 5 + packages/core/src/matching.test.ts | 91 ++++++++++ packages/core/src/query.ts | 158 +++++++++++++----- packages/core/src/text.ts | 46 +++++ 11 files changed, 319 insertions(+), 49 deletions(-) create mode 100644 eval/fixtures/a10-ocr-noise/app/InvoiceDashboard.tsx create mode 100644 eval/fixtures/a10-ocr-noise/app/ReconciliationReport.tsx create mode 100644 eval/fixtures/a10-ocr-noise/app/SettingsPanel.tsx create mode 100644 eval/fixtures/a10-ocr-noise/app/ShipmentTracker.tsx create mode 100644 eval/fixtures/a10-ocr-noise/golden.json create mode 100644 packages/core/src/matching.test.ts diff --git a/TRACKER.md b/TRACKER.md index c297727..26b81ea 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 4 β€” Matching engine -- **Next step:** 4.1 β€” Term matching v2 -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5 +- **Next step:** 4.2 β€” Structural matching +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1 - **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) ## What CodeRadar is @@ -218,7 +218,7 @@ The heart of the project. C1 and B1 live here. ## Phase 4 β€” Matching engine -### [ ] 4.1 Term matching v2 +### [x] 4.1 Term matching v2 **Failure modes:** A4, A7 (matching half), A10 **Build:** replace `matchComponentsByText` with a scorer over instances: - Normalization (from 1.5) both sides; fuzzy token match (edit distance ≀ 1 per token, token-set overlap) for OCR noise. diff --git a/eval/fixtures/a10-ocr-noise/app/InvoiceDashboard.tsx b/eval/fixtures/a10-ocr-noise/app/InvoiceDashboard.tsx new file mode 100644 index 0000000..8db7e5c --- /dev/null +++ b/eval/fixtures/a10-ocr-noise/app/InvoiceDashboard.tsx @@ -0,0 +1,8 @@ +export function InvoiceDashboard() { + return ( +
+

Invoice Dashboard

+

Outstanding balances by customer

+
+ ); +} diff --git a/eval/fixtures/a10-ocr-noise/app/ReconciliationReport.tsx b/eval/fixtures/a10-ocr-noise/app/ReconciliationReport.tsx new file mode 100644 index 0000000..87d7bac --- /dev/null +++ b/eval/fixtures/a10-ocr-noise/app/ReconciliationReport.tsx @@ -0,0 +1,8 @@ +export function ReconciliationReport() { + return ( +
+

Account Reconciliation

+

Match transactions against statements

+
+ ); +} diff --git a/eval/fixtures/a10-ocr-noise/app/SettingsPanel.tsx b/eval/fixtures/a10-ocr-noise/app/SettingsPanel.tsx new file mode 100644 index 0000000..333c094 --- /dev/null +++ b/eval/fixtures/a10-ocr-noise/app/SettingsPanel.tsx @@ -0,0 +1,8 @@ +export function SettingsPanel() { + return ( +
+

Settings

+

Preferences and account options

+
+ ); +} diff --git a/eval/fixtures/a10-ocr-noise/app/ShipmentTracker.tsx b/eval/fixtures/a10-ocr-noise/app/ShipmentTracker.tsx new file mode 100644 index 0000000..1d73c4e --- /dev/null +++ b/eval/fixtures/a10-ocr-noise/app/ShipmentTracker.tsx @@ -0,0 +1,8 @@ +export function ShipmentTracker() { + return ( +
+

Shipment Tracking

+

Delivery status and carrier updates

+
+ ); +} diff --git a/eval/fixtures/a10-ocr-noise/golden.json b/eval/fixtures/a10-ocr-noise/golden.json new file mode 100644 index 0000000..8eb24dd --- /dev/null +++ b/eval/fixtures/a10-ocr-noise/golden.json @@ -0,0 +1,18 @@ +{ + "failureMode": "A10", + "note": "OCR noise: screenshot text arrives misspelled ('Acount Reconcilliation', 'Invoise Dashbord'). Fuzzy token matching β€” a small, length-scaled edit distance on long distinctive tokens β€” still lands the right component in the top 3 (here, top 1). Short common tokens must still match exactly so noise doesn't collide unrelated components.", + "expect": { + "components": [ + { "name": "ReconciliationReport", "instances": 0 }, + { "name": "InvoiceDashboard", "instances": 0 }, + { "name": "ShipmentTracker", "instances": 0 }, + { "name": "SettingsPanel", "instances": 0 } + ], + "queries": [ + { "terms": ["Acount Reconcilliation"], "status": "ok", "top": "ReconciliationReport", "topK": 3 }, + { "terms": ["Invoise Dashbord"], "status": "ok", "top": "InvoiceDashboard", "topK": 3 }, + { "terms": ["Shipmnt Tracking"], "status": "ok", "top": "ShipmentTracker", "topK": 3 }, + { "terms": ["Reconciliation"], "status": "ok", "top": "ReconciliationReport" } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index 5e7cf51..0c5cf7f 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -224,9 +224,15 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): if (!passed) { detail = `expected status ${query.status}, got ${result.status}`; } else if (query.status === "ok" && query.top !== undefined) { - const top = result.candidates[0]?.value.component.name; - passed = top === query.top; - if (!passed) detail = `expected top ${query.top}, got ${top ?? "none"}`; + const k = query.topK ?? 1; + const topNames = result.candidates.slice(0, k).map((c) => c.value.component.name); + passed = topNames.includes(query.top); + if (!passed) { + detail = + k > 1 + ? `expected ${query.top} in top ${k}, got [${topNames.join(", ")}]` + : `expected top ${query.top}, got ${topNames[0] ?? "none"}`; + } } finalize("queries", id, passed, query.expectedFail, detail); } diff --git a/eval/src/golden.ts b/eval/src/golden.ts index dd511a6..dc87240 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -38,6 +38,11 @@ export interface GoldenQuery { status: "ok" | "ambiguous" | "declined"; /** Required top-1 component name when status is "ok". */ top?: string; + /** + * When set, `top` need only appear within the first `topK` candidates rather + * than at rank 1 β€” the honest bar for OCR-noisy input (failure mode A10). + */ + topK?: number; expectedFail?: string; } diff --git a/packages/core/src/matching.test.ts b/packages/core/src/matching.test.ts new file mode 100644 index 0000000..e010ee3 --- /dev/null +++ b/packages/core/src/matching.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { matchComponentsByText } from "./query.js"; +import { editDistance, fuzzyTokenMatch } from "./text.js"; +import { type ComponentNode, type LineageGraph, nodeId } from "./types.js"; + +const loc = (file: string) => ({ file, line: 1, endLine: 1 }); + +function component(name: string, texts: string[]): ComponentNode { + return { + id: nodeId("component", `${name}.tsx`, name), + kind: "component", + name, + loc: loc(`${name}.tsx`), + exportName: name, + props: [], + renderedText: texts.map((text) => ({ text, source: "jsx" as const })), + rendersComponents: [], + }; +} + +function graph(components: ComponentNode[]): LineageGraph { + return { + version: 2, + root: "/app", + generatedAt: "2026-01-01T00:00:00.000Z", + generator: "test", + nodes: components, + edges: [], + }; +} + +describe("editDistance / fuzzyTokenMatch (TRACKER 4.1)", () => { + it("bounds the edit distance and abandons past the budget", () => { + expect(editDistance("kitten", "sitting", 3)).toBe(3); + expect(editDistance("abcdef", "uvwxyz", 2)).toBe(3); // max + 1 sentinel + }); + + it("tolerates OCR slips on long tokens but keeps short tokens strict", () => { + expect(fuzzyTokenMatch("reconciliation", "reconcilliation")).toBe(true); + expect(fuzzyTokenMatch("dashboard", "dashbord")).toBe(true); + expect(fuzzyTokenMatch("save", "safe")).toBe(false); // short β†’ exact only + }); +}); + +describe("matchComponentsByText scorer (TRACKER 4.1, A4/A10)", () => { + const forms = [ + component("SettingsForm", ["Save", "Notifications"]), + component("ProfileForm", ["Save", "Display name"]), + component("BillingForm", ["Save", "Card number"]), + ]; + + it("a lone generic term is honestly ambiguous, not a coin flip", () => { + const result = matchComponentsByText(graph(forms), ["Save"]); + expect(result.status).toBe("ambiguous"); + expect(result.disambiguation).toBeDefined(); + }); + + it("a distinctive term breaks the tie via rarity weighting", () => { + const result = matchComponentsByText(graph(forms), ["Save", "Card number"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("BillingForm"); + }); + + it("respects word order β€” 'Order deleted' β‰  'Delete order'", () => { + const g = graph([ + component("Toast", ["Order deleted"]), + component("DeleteButton", ["Delete order"]), + ]); + expect(matchComponentsByText(g, ["Order deleted"]).candidates[0]?.value.component.name).toBe( + "Toast", + ); + expect(matchComponentsByText(g, ["Delete order"]).candidates[0]?.value.component.name).toBe( + "DeleteButton", + ); + }); + + it("matches OCR-noisy distinctive text", () => { + const g = graph([ + component("ReconciliationReport", ["Account Reconciliation"]), + component("InvoiceDashboard", ["Invoice Dashboard"]), + ]); + const result = matchComponentsByText(g, ["Acount Reconcilliation"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("ReconciliationReport"); + }); + + it("declines when nothing matches", () => { + expect(matchComponentsByText(graph(forms), ["Purchase history"]).status).toBe("declined"); + }); +}); diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index a433f52..ace2690 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -14,7 +14,7 @@ import { ok, type QueryResult, } from "./result.js"; -import { normalizeText, textMatches } from "./text.js"; +import { fuzzyTokenMatch, normalizeText, textMatches, tokenize } from "./text.js"; import type { ComponentNode, DataSourceNode, @@ -25,6 +25,7 @@ import type { LineageEdge, LineageGraph, LineageNode, + RenderedText, RouteNode, SourceLocation, StateNode, @@ -38,56 +39,108 @@ export interface ComponentMatch { } /** - * Rank components by overlap between `terms` (words/phrases read off a - * screenshot or ticket) and each component's statically rendered text. + * Rank components by how well `terms` (words/phrases read off a screenshot or + * ticket) match each component's statically rendered text (TRACKER step 4.1). * - * Returns `ambiguous` when several components tie at the top score, - * `declined("no-signal")` when nothing matches at all. + * Three ideas beyond raw overlap: + * - **Rarity weighting** β€” a term's weight is its inverse document frequency + * across the graph, so "Save" (everywhere) counts for almost nothing while + * "Reconciliation" (one component) dominates. + * - **Fuzzy tokens** β€” long tokens match within a small edit distance, so OCR + * slips ("Reconcilliation") still land (failure mode A10). + * - **Combination bonus** β€” several distinct terms co-occurring in one + * component outrank the same terms scattered across many. + * + * Returns `ambiguous` when the leaders tie on rarity-weighted score (a lone + * generic term is honestly ambiguous), `declined("no-signal")` on no match. */ export function matchComponentsByText( graph: LineageGraph, terms: string[], ): QueryResult { - const needles = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1); - if (needles.length === 0) return declined("no-signal"); + const queryTerms = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1); + if (queryTerms.length === 0) return declined("no-signal"); const instancesByDefinition = groupInstances(graph); - const scored: Array<{ match: ComponentMatch; score: number; evidence: Evidence[] }> = []; - - for (const node of graph.nodes) { - if (node.kind !== "component") continue; - const matchedText: string[] = []; - const evidence: Evidence[] = []; - for (const needle of needles) { - const hit = node.renderedText.find((entry) => - textMatches(normalizeText(entry.text), needle), - ); - if (hit !== undefined) { - matchedText.push(hit.text.toLowerCase()); - const provenance = - hit.source === "i18n" - ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` - : hit.branch !== undefined - ? ` (renders only when ${hit.branch})` - : ""; - evidence.push({ - kind: "text-match", - detail: `"${needle}" matched rendered text "${hit.text}"${provenance}`, - loc: node.loc, - }); + const components = graph.nodes.filter((n): n is ComponentNode => n.kind === "component"); + + // Document frequency per token, for rarity (IDF) weighting. + const documentFrequency = new Map(); + for (const component of components) { + const tokens = new Set(); + for (const entry of component.renderedText) for (const t of tokenize(entry.text)) tokens.add(t); + for (const t of tokens) documentFrequency.set(t, (documentFrequency.get(t) ?? 0) + 1); + } + const total = components.length || 1; + const idf = (token: string): number => { + let df = documentFrequency.get(token); + if (df === undefined) { + // Not an exact token β€” charge the rarity of the closest fuzzy match. + for (const [candidate, count] of documentFrequency) { + if (fuzzyTokenMatch(candidate, token)) df = Math.max(df ?? 0, count); } } - if (matchedText.length > 0) { - scored.push({ - match: { - component: node, - instances: instancesByDefinition.get(node.id) ?? [], - matchedText, - }, - score: matchedText.length / needles.length, - evidence, + return Math.log((total + 1) / ((df ?? 0.5) + 0.5)); + }; + + // The rendered-text entry a phrase term matches: directly (exact/substring/ + // wildcard) or as an ordered, fuzzy, contiguous run of its tokens β€” order + // matters, so "Order deleted" does not match "Delete order", but OCR slips + // in any single token still land. Returns the entry (for provenance) or null. + const matchingEntry = (term: string, component: ComponentNode): RenderedText | null => { + const termTokens = tokenize(term); + if (termTokens.length === 0) return null; + for (const entry of component.renderedText) { + if (textMatches(normalizeText(entry.text), term)) return entry; + if (containsPhrase(tokenize(entry.text), termTokens)) return entry; + } + return null; + }; + + const termWeight = (term: string): number => + tokenize(term).reduce((sum, t) => sum + idf(t), 0); + + const scored: Array<{ + match: ComponentMatch; + score: number; + coverage: number; + evidence: Evidence[]; + }> = []; + + for (const component of components) { + const matched: string[] = []; + const evidence: Evidence[] = []; + let weight = 0; + for (const term of queryTerms) { + const hit = matchingEntry(term, component); + if (hit === null) continue; + const w = termWeight(term); + matched.push(term); + weight += w; + const provenance = + hit.source === "i18n" + ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` + : hit.branch !== undefined + ? ` (renders only when ${hit.branch})` + : ""; + evidence.push({ + kind: "text-match", + detail: `"${term}" matched rendered text "${hit.text}"${provenance} β€” rarity weight ${w.toFixed(2)}`, + loc: component.loc, }); } + if (matched.length === 0) continue; + const combination = 1 + 0.5 * (matched.length - 1); + scored.push({ + match: { + component, + instances: instancesByDefinition.get(component.id) ?? [], + matchedText: matched, + }, + score: weight * combination, + coverage: matched.length / queryTerms.length, + evidence, + }); } if (scored.length === 0) return declined("no-signal"); @@ -97,18 +150,19 @@ export function matchComponentsByText( ); const candidates: Candidate[] = scored.map((s) => ({ value: s.match, - confidence: confidenceFromScore(s.score), + confidence: confidenceFromScore(s.coverage), evidence: s.evidence, })); const top = scored[0]; - const tied = top === undefined ? [] : scored.filter((s) => s.score === top.score); + const tied = + top === undefined ? [] : scored.filter((s) => Math.abs(s.score - top.score) < 1e-9); if (tied.length > 1) { const names = tied.map((s) => s.match.component.name).join(", "); return ambiguous( candidates, - `Multiple components match equally well (${names}). ` + - `Which file or page is the screenshot from, or what other text is visible?`, + `Several components match equally well (${names}). ` + + `Which page is the screenshot from, or what other distinctive text is visible?`, ); } return ok(candidates); @@ -482,6 +536,24 @@ export function journeys( return ok([{ value: paths, confidence: confidenceFromScore(0.9), evidence }]); } +/** True when `phrase` tokens appear as a contiguous, in-order, fuzzy run in `haystack`. */ +function containsPhrase(haystack: string[], phrase: string[]): boolean { + if (phrase.length === 0 || phrase.length > haystack.length) return false; + for (let start = 0; start + phrase.length <= haystack.length; start += 1) { + let ok = true; + for (let k = 0; k < phrase.length; k += 1) { + const h = haystack[start + k]; + const p = phrase[k]; + if (h === undefined || p === undefined || !fuzzyTokenMatch(h, p)) { + ok = false; + break; + } + } + if (ok) return true; + } + return false; +} + function groupInstances(graph: LineageGraph): Map { const byDefinition = new Map(); for (const node of graph.nodes) { diff --git a/packages/core/src/text.ts b/packages/core/src/text.ts index 03ab32f..671eda4 100644 --- a/packages/core/src/text.ts +++ b/packages/core/src/text.ts @@ -42,3 +42,49 @@ function foldPlural(token: string): string { if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1); return token; } + +/** Normalized significant tokens of a string (drops empties). */ +export function tokenize(input: string): string[] { + return normalizeText(input) + .split(" ") + .filter((t) => t.length > 0); +} + +/** + * Levenshtein distance between `a` and `b`, abandoned once it exceeds `max` + * (returns `max + 1`). Bounding keeps the matcher's fuzzy comparisons cheap. + */ +export function editDistance(a: string, b: string, max: number): number { + if (a === b) return 0; + if (Math.abs(a.length - b.length) > max) return max + 1; + const prev = new Array(b.length + 1); + const curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j += 1) prev[j] = j; + for (let i = 1; i <= a.length; i += 1) { + curr[0] = i; + let rowMin = curr[0]; + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + const value = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost); + curr[j] = value; + if (value < rowMin) rowMin = value; + } + if (rowMin > max) return max + 1; + for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j] ?? 0; + } + return prev[b.length] ?? max + 1; +} + +/** + * OCR-tolerant token equality: exact after normalization, or within a small + * edit distance that scales with length β€” short tokens ("save") must match + * exactly so they don't collide ("safe"), longer distinctive words tolerate the + * 1–2 character slips OCR introduces ("reconcilliation" β‰ˆ "reconciliation"). + */ +export function fuzzyTokenMatch(a: string, b: string): boolean { + if (a === b) return true; + const len = Math.max(a.length, b.length); + if (len < 5) return false; + const budget = len <= 7 ? 1 : 2; + return editDistance(a, b, budget) <= budget; +} From 0e784043cb60da3b6cb7829ccc1b4f4403e9eb49 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 02:00:31 +0530 Subject: [PATCH 25/49] feat(match): structural signatures + structure-aware matching (A1/A3/A12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match components by shape when text is scarce (TRACKER step 4.2): - Parser emits a StructuralSignature per component β€” counts of tables, columns (th), forms, inputs, buttons, links, images, headings, lists, and repeated (.map) items β€” folding raw DOM tags and common design-system names (, , , …) into the same buckets. - New matchComponents(graph, { terms?, structure? }) combines the rarity- weighted text score (4.1) with a structure-descriptor fit into one ranking; matchComponentsByText now wraps it. A vision-style descriptor ("a table with four columns and a card grid") ranks the right component even with zero text. Schema: ComponentNode.structure (StructuralSignature) + StructureDescriptor; JSON schema regenerated. Eval GoldenQuery gains an optional `structure`. Fixture a1-no-static-text (dashboard/form/gallery/list, no literals) matched by structure alone. 36 core + 97 parser tests pass; eval green. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 6 +- .../a1-no-static-text/app/ImageGallery.tsx | 14 ++++ .../a1-no-static-text/app/LoginForm.tsx | 9 +++ .../a1-no-static-text/app/SettingsList.tsx | 14 ++++ .../a1-no-static-text/app/StatsDashboard.tsx | 42 ++++++++++++ eval/fixtures/a1-no-static-text/golden.json | 17 +++++ eval/src/checks.ts | 6 +- eval/src/golden.ts | 2 + packages/core/src/query.ts | 68 +++++++++++++++++-- packages/core/src/types.ts | 41 +++++++++++ packages/parser-react/src/scan.ts | 65 ++++++++++++++++++ packages/parser-react/src/structure.test.ts | 49 +++++++++++++ schemas/lineage-graph.schema.json | 59 +++++++++++++++- 13 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 eval/fixtures/a1-no-static-text/app/ImageGallery.tsx create mode 100644 eval/fixtures/a1-no-static-text/app/LoginForm.tsx create mode 100644 eval/fixtures/a1-no-static-text/app/SettingsList.tsx create mode 100644 eval/fixtures/a1-no-static-text/app/StatsDashboard.tsx create mode 100644 eval/fixtures/a1-no-static-text/golden.json create mode 100644 packages/parser-react/src/structure.test.ts diff --git a/TRACKER.md b/TRACKER.md index 26b81ea..2da687a 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 4 β€” Matching engine -- **Next step:** 4.2 β€” Structural matching -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1 +- **Next step:** 4.3 β€” Most-specific-subtree resolution +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1–4.2 - **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) ## What CodeRadar is @@ -226,7 +226,7 @@ The heart of the project. C1 and B1 live here. - Combination bonus: co-occurrence of multiple terms in one instance subtree outweighs scattered singles. **Accept:** `a4-generic-text` green ("Save" alone β†’ `ambiguous`; "Save" + "invoice details" β†’ correct top-1); noisy-term fixture `a10-ocr-noise` (misspelled terms) top-3 correct. -### [ ] 4.2 Structural matching +### [x] 4.2 Structural matching **Failure modes:** A1, A3, A12 **Build:** structural signature per instance subtree (child element kinds/counts: table with N columns, form with M inputs, card grid). Query side accepts a structure descriptor (from vision output: "a table with columns Name, Email, Actions") and scores against signatures. Text and structure scores combine into one ranking. **Accept:** fixture `a1-no-static-text` (dashboard, zero literals) top-3 correct via structure alone. diff --git a/eval/fixtures/a1-no-static-text/app/ImageGallery.tsx b/eval/fixtures/a1-no-static-text/app/ImageGallery.tsx new file mode 100644 index 0000000..f5d522a --- /dev/null +++ b/eval/fixtures/a1-no-static-text/app/ImageGallery.tsx @@ -0,0 +1,14 @@ +interface Photo { + id: string; + src: string; +} + +export function ImageGallery({ photos }: { photos: Photo[] }) { + return ( +
+ {photos.map((p) => ( + + ))} +
+ ); +} diff --git a/eval/fixtures/a1-no-static-text/app/LoginForm.tsx b/eval/fixtures/a1-no-static-text/app/LoginForm.tsx new file mode 100644 index 0000000..0af2494 --- /dev/null +++ b/eval/fixtures/a1-no-static-text/app/LoginForm.tsx @@ -0,0 +1,9 @@ +export function LoginForm({ onSubmit }: { onSubmit: () => void }) { + return ( + + + +
+ + + + + + + + + + {rows.map((r) => ( + + + + ))} + +
{r.id}
+ + ); +} diff --git a/eval/fixtures/a1-no-static-text/golden.json b/eval/fixtures/a1-no-static-text/golden.json new file mode 100644 index 0000000..a610ff3 --- /dev/null +++ b/eval/fixtures/a1-no-static-text/golden.json @@ -0,0 +1,17 @@ +{ + "failureMode": "A1", + "note": "No static text: a dashboard, form, gallery and list carry zero literals, so text matching finds nothing. Structural signatures (table with N columns, form with M inputs, card/image grid) let a vision-style structure descriptor still rank the right component top-1.", + "expect": { + "components": [ + { "name": "StatsDashboard", "instances": 0 }, + { "name": "LoginForm", "instances": 0 }, + { "name": "ImageGallery", "instances": 0 }, + { "name": "SettingsList", "instances": 0 } + ], + "queries": [ + { "terms": [], "structure": { "table": true, "columns": 4, "cards": 2 }, "status": "ok", "top": "StatsDashboard", "topK": 3 }, + { "terms": [], "structure": { "form": true, "inputs": 2, "buttons": 1 }, "status": "ok", "top": "LoginForm", "topK": 3 }, + { "terms": [], "structure": { "table": true }, "status": "ok", "top": "StatsDashboard" } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index 0c5cf7f..a003d81 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -3,7 +3,7 @@ import { journeys, type LineageGraph, - matchComponentsByText, + matchComponents, traceLineage, } from "@coderadar/core"; @@ -217,8 +217,8 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): } for (const query of golden.expect.queries ?? []) { - const id = `query:${query.terms.join("+")}`; - const result = matchComponentsByText(graph, query.terms); + const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`; + const result = matchComponents(graph, { terms: query.terms, structure: query.structure }); let passed = result.status === query.status; let detail: string | undefined; if (!passed) { diff --git a/eval/src/golden.ts b/eval/src/golden.ts index dc87240..cefd07d 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -35,6 +35,8 @@ export interface GoldenForbidden { export interface GoldenQuery { terms: string[]; + /** Optional structural descriptor (Phase 4.2), e.g. { table: true, columns: 4 }. */ + structure?: import("@coderadar/core").StructureDescriptor; status: "ok" | "ambiguous" | "declined"; /** Required top-1 component name when status is "ok". */ top?: string; diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index ace2690..87f2718 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -29,6 +29,8 @@ import type { RouteNode, SourceLocation, StateNode, + StructuralSignature, + StructureDescriptor, } from "./types.js"; export interface ComponentMatch { @@ -54,12 +56,36 @@ export interface ComponentMatch { * Returns `ambiguous` when the leaders tie on rarity-weighted score (a lone * generic term is honestly ambiguous), `declined("no-signal")` on no match. */ +/** A screenshot/ticket query: visible text, a structure descriptor, or both. */ +export interface MatchQuery { + terms?: string[]; + structure?: StructureDescriptor; +} + +/** Weight of a full structural match relative to a rare matched term. */ +const STRUCTURE_WEIGHT = 3; + +/** Back-compat text-only entry point. */ export function matchComponentsByText( graph: LineageGraph, terms: string[], ): QueryResult { - const queryTerms = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1); - if (queryTerms.length === 0) return declined("no-signal"); + return matchComponents(graph, { terms }); +} + +/** + * Match components by rendered text (rarity-weighted, fuzzy β€” step 4.1) and/or + * structural shape (step 4.2). Text and structure scores combine into one + * ranking, so a dashboard with no static text still matches on "a table with + * four columns and a card grid". + */ +export function matchComponents( + graph: LineageGraph, + query: MatchQuery, +): QueryResult { + const queryTerms = (query.terms ?? []).map((t) => normalizeText(t)).filter((t) => t.length > 1); + const descriptor = query.structure; + if (queryTerms.length === 0 && descriptor === undefined) return declined("no-signal"); const instancesByDefinition = groupInstances(graph); const components = graph.nodes.filter((n): n is ComponentNode => n.kind === "component"); @@ -129,16 +155,27 @@ export function matchComponentsByText( loc: component.loc, }); } - if (matched.length === 0) continue; - const combination = 1 + 0.5 * (matched.length - 1); + const textScore = matched.length > 0 ? weight * (1 + 0.5 * (matched.length - 1)) : 0; + + const structureFit = descriptor !== undefined ? structureScore(component.structure, descriptor) : 0; + if (structureFit > 0) { + evidence.push({ + kind: "structure", + detail: `structural shape matched the descriptor (fit ${structureFit.toFixed(2)})`, + loc: component.loc, + }); + } + + if (matched.length === 0 && structureFit === 0) continue; + const coverage = queryTerms.length > 0 ? matched.length / queryTerms.length : structureFit; scored.push({ match: { component, instances: instancesByDefinition.get(component.id) ?? [], matchedText: matched, }, - score: weight * combination, - coverage: matched.length / queryTerms.length, + score: textScore + structureFit * STRUCTURE_WEIGHT, + coverage: Math.max(coverage, structureFit), evidence, }); } @@ -536,6 +573,25 @@ export function journeys( return ok([{ value: paths, confidence: confidenceFromScore(0.9), evidence }]); } +/** + * Fraction (0–1) of a structure descriptor's specified expectations that a + * component's signature satisfies. Counts and columns match with tolerance so + * OCR/vision miscounts still land. + */ +function structureScore(sig: StructuralSignature, desc: StructureDescriptor): number { + const checks: boolean[] = []; + if (desc.table !== undefined) checks.push(desc.table === sig.table > 0); + if (desc.form !== undefined) checks.push(desc.form === sig.form > 0); + if (desc.list !== undefined) checks.push(desc.list === (sig.list > 0 || sig.repeated > 0)); + if (desc.columns !== undefined) checks.push(Math.abs(sig.columns - desc.columns) <= 1); + if (desc.inputs !== undefined) checks.push(sig.input >= desc.inputs - 1); + if (desc.buttons !== undefined) checks.push(sig.button >= desc.buttons - 1); + if (desc.images !== undefined) checks.push(sig.image >= desc.images - 1); + if (desc.cards !== undefined) checks.push(sig.repeated >= Math.max(1, desc.cards - 1)); + if (checks.length === 0) return 0; + return checks.filter(Boolean).length / checks.length; +} + /** True when `phrase` tokens appear as a contiguous, in-order, fuzzy run in `haystack`. */ function containsPhrase(haystack: string[], phrase: string[]): boolean { if (phrase.length === 0 || phrase.length > haystack.length) return false; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f0b7ae9..a6ab9d1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -67,6 +67,28 @@ export interface RenderedText { template?: boolean; } +/** + * Counts of the structural elements a component renders β€” the signal for + * matching a screenshot with little or no static text (TRACKER step 4.2, + * failure modes A1/A3/A12). Raw DOM tags plus common design-system aliases + * (``, ``, …) are folded into these buckets. + */ +export interface StructuralSignature { + table: number; + /** Column headers β€” the max `
` (or design-system column) count in a table. */ + columns: number; + form: number; + input: number; + button: number; + link: number; + image: number; + heading: number; + /** `
    `/`
      `/`
    1. ` groupings. */ + list: number; + /** `.map(...)` JSX renders β€” repeated cards/rows/grid items. */ + repeated: number; +} + /** A React component definition β€” the code, not a usage. */ export interface ComponentNode extends BaseNode { kind: "component"; @@ -82,6 +104,25 @@ export interface ComponentNode extends BaseNode { renderedText: RenderedText[]; /** Names of components this component renders in its JSX (deduplicated). */ rendersComponents: string[]; + /** Structural fingerprint of the rendered subtree (Phase 4.2). */ + structure: StructuralSignature; +} + +/** + * A structural query, e.g. from vision output "a table with columns Name, + * Email, Actions". Only the specified fields are scored against a signature. + */ +export interface StructureDescriptor { + table?: boolean; + /** Expected column count (matched within Β±1). */ + columns?: number; + form?: boolean; + inputs?: number; + buttons?: number; + images?: number; + list?: boolean; + /** Expected count of repeated items (cards/rows), matched with tolerance. */ + cards?: number; } /** diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index d4f624d..2908ddb 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -11,6 +11,7 @@ import { nodeId, type RenderedText, type SourceLocation, + type StructuralSignature, } from "@coderadar/core"; import { type ArrowFunction, @@ -198,6 +199,7 @@ export function scanReact(options: ScanOptions): LineageGraph { ...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []), ], rendersComponents: extractRenderedComponents(decl.fn), + structure: extractStructure(decl.fn), ...(usesPortal ? { flags: ["portal"] } : {}), }); collectInstanceSites(decl.fn, id, file, pendingInstances); @@ -492,6 +494,67 @@ function classifyTest(test: Node, flagCallees: ReadonlySet): EdgeConditi return undefined; } +/** + * Structural fingerprint of a component's rendered JSX (TRACKER step 4.2): + * counts of tables, columns, forms, inputs, etc., folding raw DOM tags and + * common design-system component names into the same buckets so a screenshot + * with little text still matches on shape (failure modes A1/A3/A12). + */ +function emptyStructure(): StructuralSignature { + return { + table: 0, + columns: 0, + form: 0, + input: 0, + button: 0, + link: 0, + image: 0, + heading: 0, + list: 0, + repeated: 0, + }; +} + +function extractStructure(fn: Node): StructuralSignature { + const sig = emptyStructure(); + let thCount = 0; + const tags = [ + ...fn.getDescendantsOfKind(SyntaxKind.JsxOpeningElement), + ...fn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement), + ]; + for (const el of tags) { + const raw = el.getTagNameNode().getText(); + const tag = raw.split(".").pop() ?? raw; + const lower = tag.toLowerCase(); + if (lower === "table" || /^(data)?(table|grid|datagrid)$/.test(lower)) sig.table += 1; + else if (lower === "form" || tag === "Formik") sig.form += 1; + else if ( + lower === "input" || + lower === "select" || + lower === "textarea" || + /^(textfield|input|select|checkbox|radio|switch|datepicker)$/.test(lower) + ) + sig.input += 1; + else if (lower === "button" || /^(iconbutton|button)$/.test(lower)) sig.button += 1; + else if (lower === "a" || tag === "Link" || tag === "NavLink") sig.link += 1; + else if (lower === "img" || tag === "Image" || tag === "Avatar") sig.image += 1; + else if (/^h[1-6]$/.test(lower) || tag === "Heading" || tag === "Title") sig.heading += 1; + else if (lower === "ul" || lower === "ol" || tag === "List") sig.list += 1; + if (lower === "th") thCount += 1; + } + sig.columns = thCount; + // Repeated items: `.map(cb)` where the callback returns JSX (rows, cards, grid). + for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== "map") continue; + const cb = call.getArguments()[0]; + if (cb !== undefined && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb)) && returnsJsx(cb)) { + sig.repeated += 1; + } + } + return sig; +} + function extractRenderedComponents(fn: Node): string[] { const names = new Set(); const record = (tagName: string): void => { @@ -1089,6 +1152,7 @@ function scanClassComponents( props: [], renderedText: [], rendersComponents: [], + structure: emptyStructure(), flags: ["incomplete"], }); continue; @@ -1106,6 +1170,7 @@ function scanClassComponents( ...(localeTable !== null ? i18nRenderedText(render, localeTable) : []), ], rendersComponents: extractRenderedComponents(render), + structure: extractStructure(render), }); collectInstanceSites(render, id, file, pendingInstances); // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. diff --git a/packages/parser-react/src/structure.test.ts b/packages/parser-react/src/structure.test.ts new file mode 100644 index 0000000..f3d8129 --- /dev/null +++ b/packages/parser-react/src/structure.test.ts @@ -0,0 +1,49 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { matchComponents } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const a1 = resolveHookEdges( + scanReact({ + root: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/a1-no-static-text/app", + ), + }), +); + +const structureOf = (name: string) => + a1.nodes.find((n) => n.kind === "component" && n.name === name)?.kind === "component" + ? (a1.nodes.find((n) => n.kind === "component" && n.name === name) as { structure: unknown }).structure + : undefined; + +describe("structural signatures (a1 fixture, TRACKER 4.2)", () => { + it("counts a table's columns and repeated items", () => { + expect(structureOf("StatsDashboard")).toMatchObject({ table: 1, columns: 4, repeated: 2 }); + }); + + it("counts form inputs and buttons, folding raw DOM tags", () => { + expect(structureOf("LoginForm")).toMatchObject({ form: 1, input: 2, button: 1 }); + }); + + it("these components carry no static text at all", () => { + for (const name of ["StatsDashboard", "LoginForm", "ImageGallery", "SettingsList"]) { + const c = a1.nodes.find((n) => n.kind === "component" && n.name === name); + expect(c?.kind === "component" ? c.renderedText.length : -1).toBe(0); + } + }); + + it("matches a text-free dashboard by structure descriptor alone", () => { + const result = matchComponents(a1, { structure: { table: true, columns: 4, cards: 2 } }); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("StatsDashboard"); + }); + + it("matches a form by its shape, not its (absent) text", () => { + const result = matchComponents(a1, { structure: { form: true, inputs: 2, buttons: 1 } }); + expect(result.candidates[0]?.value.component.name).toBe("LoginForm"); + }); +}); diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 1167c26..5ca662b 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -144,6 +144,10 @@ "type": "string" }, "description": "Names of components this component renders in its JSX (deduplicated)." + }, + "structure": { + "$ref": "#/definitions/StructuralSignature", + "description": "Structural fingerprint of the rendered subtree (Phase 4.2)." } }, "required": [ @@ -154,7 +158,8 @@ "name", "props", "renderedText", - "rendersComponents" + "rendersComponents", + "structure" ], "additionalProperties": false, "description": "A React component definition β€” the code, not a usage." @@ -223,6 +228,58 @@ "additionalProperties": false, "description": "One piece of text a component can render, with its provenance." }, + "StructuralSignature": { + "type": "object", + "properties": { + "table": { + "type": "number" + }, + "columns": { + "type": "number", + "description": "Column headers β€” the max `
` (or design-system column) count in a table." + }, + "form": { + "type": "number" + }, + "input": { + "type": "number" + }, + "button": { + "type": "number" + }, + "link": { + "type": "number" + }, + "image": { + "type": "number" + }, + "heading": { + "type": "number" + }, + "list": { + "type": "number", + "description": "`