diff --git a/.codesandbox/tasks.json b/.codesandbox/tasks.json new file mode 100644 index 0000000..36a1d82 --- /dev/null +++ b/.codesandbox/tasks.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://codesandbox.io/schemas/tasks.json", + "setupTasks": [ + { "name": "Install dependencies", "command": "corepack enable && pnpm install" }, + { "name": "Build all packages", "command": "pnpm -r build" } + ], + "tasks": { + "test": { + "name": "Unit tests", + "command": "pnpm test" + }, + "eval": { + "name": "Eval gate", + "command": "pnpm eval" + }, + "typecheck": { + "name": "Typecheck", + "command": "pnpm typecheck" + }, + "scan-demo": { + "name": "Scan the demo fixture", + "command": "node packages/cli/dist/index.js scan eval/fixtures/c1-shared-datatable/app -o app.graph.json" + } + } +} 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/.gitignore b/.gitignore index 872d5f6..b709cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,9 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Eval outputs (scorecard is regenerated every run; history is recorded deliberately) +eval/scorecard.json + +# ui-lineage scanned graphs +*.graph.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 41f65dc..7dc8cc1 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,148 @@ -# 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 (`matchComponents`, `traceLineage`, `journeys`, `blastRadius`) | +| [`@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, test coverage, response types | +| [`@coderadar/agent-sdk`](packages/agent-sdk) | `resolveContext` / `buildBundle` β€” classifies a ticket and assembles a budgeted context bundle (deterministic, no LLM) | +| [`@coderadar/mcp`](packages/mcp) | MCP server exposing `resolve_context` Β· `find_component` Β· `trace_lineage` Β· `journeys` Β· `blast_radius` over stdio | +| [`ui-lineage`](packages/cli) | Published CLI + library bundle: `scan` Β· `find` Β· `trace` Β· `journeys` Β· `impact` Β· `resolve` Β· `bundle`, plus the `ui-lineage-mcp` server bin | -- **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 diff --git a/TRACKER.md b/TRACKER.md index baed8ad..eb0c418 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -4,9 +4,10 @@ ## Status -- **Current phase:** 0 β€” Foundations -- **Next step:** 0.1 β€” Schema v2 (instance nodes, evidence, confidence) -- **Gates passed:** none yet (Gate 0 is the first) +- **Current phase:** 6F β€” Field hardening, feedback round 1 (runs before 6.1–6.5) +- **Next step:** ship **0.4.0** to the tester for a re-validation round; then 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant β€” the fixture's custom-wrapper shape already passes; get a failing test file from the tester or ship the defensive `coverage-unmapped` half only) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.8 Β· **release 0.4.0 prepared** (all packages bumped, changelog + version strings, `npm pack` β†’ ui-lineage-0.4.0.tgz verified, publish pending) +- **Gates passed:** Gate 0 (CI + red-path, #5/#6) Β· Gate 1 (precision 1.000, recall 0.895, zero poison) Β· Gate 2 (C1 instance attribution 1.000 Β· B1 4-level handler chains Β· C6 store writers↔readers Β· A9 portals β€” scorecard 137/0/0, precision & recall 1.000) Β· Gate 3 (B3 action effects Β· B4 routers Β· B6 cyclic journeys terminate Β· B7/B8 form & non-JSX events Β· G5 flag/role conditions β€” precision & recall 1.000) Β· Gate 4 (A4 rarity Β· A10 fuzzy/OCR Β· A1 structural Β· A6 subtree Β· E3 vision annotations Β· E2 aliases Β· G4 corrections β€” high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) Β· Gate 5 (F1 context bundle Β· F2 blast radius Β· F3 test coverage Β· F4 response schema Β· F5 git history Β· MCP server over stdio β€” scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** β€” ticket in β†’ budgeted context bundle out, over MCP) ## What CodeRadar is @@ -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::#`. @@ -57,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). @@ -66,16 +67,16 @@ 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. -### [ ] 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.** @@ -87,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). @@ -96,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. @@ -104,12 +105,12 @@ 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`). -### [ ] 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). @@ -117,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. @@ -125,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. @@ -139,7 +140,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. The heart of the project. C1 and B1 live here. -### [ ] 2.1 Instance tree construction +### [x] 2.1 Instance tree construction **Failure modes:** C1 (graph half), A5 **Build:** cross-file pass in `parser-react`: - Resolve every JSX tag to its component definition through imports (including `export { X as Y }`, barrel files, default exports). @@ -147,7 +148,7 @@ The heart of the project. C1 and B1 live here. - Design-system components (imported from `node_modules` or a configured `designSystemPackages` list): instances are still created, flagged `external-definition` β€” the instance is ours even when the definition isn't. **Accept:** fixture `a5-design-system` green (match resolves to the usage site); instance counts asserted in c1 golden; barrel-file resolution unit-tested. -### [ ] 2.2 Prop-flow: data attribution per instance +### [x] 2.2 Prop-flow: data attribution per instance **Failure modes:** C1 (the headline) **Build:** - For each instance, connect prop values to their origins in the parent scope: identifier props trace back through variable declarations to hook results / fetch results / store reads within the parent. @@ -156,7 +157,7 @@ The heart of the project. C1 and B1 live here. - Depth limit (default 5 hops) with `flags: ["depth-limited"]` when hit. **Accept:** **c1-shared-datatable attribution assertions flip from expected-fail to green** β€” DataTable@Users β†’ `/api/users`, DataTable@Invoices β†’ `/api/invoices`, zero `forbidden` hits. Instance attribution accuracy = 1.0 on C1 fixtures. -### [ ] 2.3 Handler resolution through props +### [x] 2.3 Handler resolution through props **Failure modes:** B1 **Build:** same machinery, callback direction: - `onClick={props.onSave}` β†’ resolve `onSave` at each parent instance β†’ the passed function β†’ its body (which Phase 3 mines for effects). Chain recorded as evidence (`edge-chain`). @@ -164,7 +165,7 @@ The heart of the project. C1 and B1 live here. - Unresolvable after 4 levels β†’ `handler: null, flags: ["unresolved-prop-handler"]` β€” visible, not silent. **Accept:** fixture `b1-prop-drilled-handler` (4 levels) β‰₯ 0.85 resolution; unresolved cases flagged in graph. -### [ ] 2.4 Store adapter β€” writers ↔ readers +### [x] 2.4 Store adapter β€” writers ↔ readers **Failure modes:** C6, B2 (redux/zustand half) **Build:** - Redux/RTK: slice detection (`createSlice`), `useSelector(s => s.users)` β†’ StateNode per slice path; dispatch sites of thunks/actions that carry API results β†’ `writes-state` edges from the data source to the slice. @@ -172,7 +173,7 @@ The heart of the project. C1 and B1 live here. - `traceLineage` through a StateNode now continues to its writers: reader component β†’ slice β†’ populating API. **Accept:** fixture `c6-store-decoupled` green β€” component with **no fetch of its own** correctly attributed to the API called on a different page at login. -### [ ] 2.5 Portals, modals, toasts +### [x] 2.5 Portals, modals, toasts **Failure modes:** A9 **Build:** `createPortal` detection + adapter list for common modal/toast libs (`react-modal`, radix `Dialog`, `react-hot-toast`): the triggering instance gets a `triggers-render` edge to the portal-content instance. **Accept:** fixture `a9-modal-portal` green: matching the modal's text surfaces both the modal component and its trigger site. **Gate 2 passes.** @@ -181,12 +182,12 @@ The heart of the project. C1 and B1 live here. ## Phase 3 β€” Journey graph -### [ ] 3.1 Router adapters +### [x] 3.1 Router adapters **Failure modes:** B4 **Build:** `RouteNode` (`path`, `layout`, `guards`) in core. Adapters: React Router (`createBrowserRouter` / `` trees, nested + lazy) and Next.js file-based (app + pages router). `routes-to` edges route β†’ page-component instance tree. **Accept:** fixtures `b4-react-router`, `b4-nextjs-approuter` green: every golden route maps to its page component. -### [ ] 3.2 Action effects +### [x] 3.2 Action effects **Failure modes:** B3, B2 (dispatch half) **Build:** mine resolved handler bodies (from 2.3) for effects, each a typed `triggers` edge from the EventNode: - `navigate(...)`/`router.push(...)` β†’ `navigates-to` with route pattern (computed strings β†’ `:param` form, B3) @@ -195,7 +196,7 @@ The heart of the project. C1 and B1 live here. - `setState`/setters β†’ `writes-state` on local state **Accept:** fixture `b3-programmatic-nav` green; every effect kind covered by a unit test. -### [ ] 3.3 Journey query β€” lazy expansion +### [x] 3.3 Journey query β€” lazy expansion **Failure modes:** B5, B6 **Build:** `journeys(graph, startInstanceId | routePath, { depth })` in core: - BFS over event β†’ effect β†’ route β†’ page-instance β†’ its events…, expanding paths **at query time**; per-path visited-set for cycle handling (a node may repeat across paths, not within one); cap + `truncated` flag. @@ -203,21 +204,26 @@ 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. -### [ ] 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.** +### [x] 3.6 Cross-app hops (added β€” was a catalog gap) +**Failure modes:** B9 +**Build:** `ExternalNode` + `exits-app` / `enters-at` edges. Navigate/`window.open`/`window.location.assign`/``/`` to an absolute URL or `mailto:`/`tel:` scheme β†’ `exits-app` (event or component β†’ external, deduped by host). Deep-link/OAuth-callback route paths β†’ `enters-at` from an inbound external node. Journeys reaching an external end with an `exit` step. +**Accept:** fixture `b9-cross-app-hops` green (OAuth redirect, Stripe `window.open`, mailto link, `/auth/callback` entry). + --- ## 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. @@ -225,17 +231,17 @@ 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 -**Failure modes:** A1, A3, A12 +### [x] 4.2 Structural matching +**Failure modes:** A1, A3, A12 β€” fixtures: `a1-no-static-text`, `a3-api-text`, `a12-non-text` (structure-only matches capped at medium confidence β€” honest graceful degradation) **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. -### [ ] 4.3 Most-specific-subtree resolution +### [x] 4.3 Most-specific-subtree resolution **Failure modes:** A6 **Build:** when matches nest (Page > Section > Card all match), return the deepest instance covering the matched term/structure set; ancestors listed as `context`, not competing candidates. **Accept:** fixture `a6-composed-page` green: full-page term set β†’ page node; card-specific terms β†’ card instance with page as context. -### [ ] 4.4 Screenshot adapter +### [x] 4.4 Screenshot adapter **Failure modes:** A10, E3, A13 **Build:** `@coderadar/vision` package: - `VisionAdapter` interface: `extract(image) β†’ { terms: string[], structure: StructureDescriptor, annotations: Region[] }`. Ships with a Claude-vision implementation; OCR-only fallback stub for tests. @@ -244,7 +250,7 @@ The heart of the project. C1 and B1 live here. - **Ephemeral only** (G7): images processed in memory, never written to the graph or disk; document in the package README. **Accept:** interface unit-tested against recorded extraction outputs (no live API in CI); annotation weighting verified on `e3-annotated-screenshot` fixture (pre-extracted terms + regions checked in). -### [ ] 4.5 Confidence calibration & ambiguity protocol +### [x] 4.5 Confidence calibration & ambiguity protocol **Failure modes:** D2, D6, G1 **Build:** - Score β†’ confidence mapping calibrated on the eval set (thresholds chosen so measured precision at `high` β‰₯ 0.95 β€” recorded in `eval/calibration.json`, regenerated by an eval subcommand). @@ -252,7 +258,7 @@ The heart of the project. C1 and B1 live here. - `declined` results carry a machine-readable reason (`out-of-scope`, `not-our-app`, `no-signal`). **Accept:** poison rate ≀ 0.05 and ambiguity honesty β‰₯ 0.90 on the full matching eval set; every `high`-confidence answer in the eval set is correct. -### [ ] 4.6 Alias glossary & corrections store +### [x] 4.6 Alias glossary & corrections store **Failure modes:** E2, G4 **Build:** `aliases.yaml` (checked into the *target* repo): `"invoice widget" β†’ BillingSummaryCard`, route titles, feature names. Corrections API: `recordCorrection(terms, confirmedInstanceId)` appends to `corrections.jsonl`; both feed the matcher as first-class evidence (`kind: "alias" | "correction"`, high weight). Eval subcommand folds corrections into new ticket-eval cases. **Accept:** fixture `e2-business-vocab` green via alias; a recorded correction changes the next identical query's top-1 (integration test). **Gate 4 passes.** @@ -261,42 +267,47 @@ The heart of the project. C1 and B1 live here. ## Phase 5 β€” Context bundle & agent interface -### [ ] 5.1 `resolveContext` orchestrator +### [x] 5.1 `resolveContext` orchestrator **Failure modes:** E1, E5, E6, F6(decline), E4(decline) **Build:** `@coderadar/agent-sdk` package. `resolveContext(ticket: { text, screenshots?, links? })`: - Entry-point classification: visual (screenshot present) / textual (UI terms in prose) / behavioral ("clicking X does nothing" β†’ match on event/handler/journey vocabulary, E5) / out-of-domain (backend/infra/perf β†’ `declined`, E6) / unsupported-input (video β†’ structured decline, E4). Classification is rule-based + keyword lexicons; no LLM call inside the node (determinism, G8). - Runs the matching engine with all available signals; merges rankings. **Accept:** `eval/tickets/` suite (β‰₯ 15 hand-written tickets: 5 visual, 4 textual, 3 behavioral, 3 out-of-domain) β€” OOD rejection β‰₯ 0.95, entry-point classification accuracy β‰₯ 0.90. -### [ ] 5.2 Context-bundle contract +### [x] 5.2 Context-bundle contract **Failure modes:** F1 **Build:** `ContextBundle` type + JSON Schema (committed, drift-gated): sections `match` (instances + evidence), `lineage` (per-instance sources with patterns/methods), `journeys` (slice around the match, depth 2 default), `blastRadius`, `tests`, `history`, `warnings` (staleness, incomplete flags). Budgeter: `budgetTokens` param; sections trimmed in fixed priority order (match > lineage > blastRadius > journeys > tests > history), each trim recorded in `warnings`. **Accept:** golden bundles for 5 ticket evals; every bundle ≀ budget under a tokenizer test at budgets 2k/4k/8k; trim order unit-tested. -### [ ] 5.3 Blast radius β€” reverse traversal +### [x] 5.3 Blast radius β€” reverse traversal **Failure modes:** F2 **Build:** reverse adjacency in core: `blastRadius(nodeId)` β†’ all instances rendering this definition, all consumers of this data source/endpoint, all readers of this state slice, journeys passing through β€” each with distance. CLI `coderadar impact `. **Accept:** fixture golden: changing the c1 DataTable definition lists both page instances; changing `/api/users` lists every consumer across fixtures. +**Done:** `blastRadius(graph, target)` in core β€” a dependency-direction-aware reverse BFS (`dependencyOf` maps each edge kind to resource↔dependent, so a change propagates the right way regardless of edge direction; journey edges don't propagate impact). Resolves a target by node id, component name, endpoint, state name, or route path; returns `ImpactNode[]` (node, relation, distance) nearest-first, always high-confidence. Wired into the context bundle's `blastRadius` section (compact `Name@file:line` labels) and a CLI `impact ` command (`-d` depth cap). c1 golden `blast` block asserts both instances (d1) + both pages (d2) for the shared DataTable, and every `/api/users` consumer with an over-reach guard forbidding the invoices side. New `GoldenBlast` type + `blast` check kind in the eval harness. 5 core unit tests; eval 258/0/0, gate OK. -### [ ] 5.4 Test coverage mapping +### [x] 5.4 Test coverage mapping **Failure modes:** F3 **Build:** scan `*.test.*` / `*.spec.*` / `__tests__`: imports + rendered components (`render()`, testing-library queries) β†’ `TestNode` + `covered-by` edges. Bundle's `tests` section lists test files for matched instances and their lineage. **Accept:** fixture with co-located tests: bundle names the right test files; components without tests get `warnings: ["untested"]`. +**Done:** `TestNode` (kind `test`, framework vitest/jest/unknown) + `covered-by` edge in core (schema regenerated, drift gate green). New `detectTests` parser pass: test files are excluded from the component/instance scan (`isTestFile`) so they never emit spurious nodes, then swept β€” every component a test renders (JSX tag) or imports resolves to a `covered-by` edge (imports resolved to their source file for precise attribution, name fallback otherwise). Bundle populates `tests` from the matched component's render subtree (`componentSubtree`) and pushes an `untested` warning when the matched component has no coverage; `blastRadius` counts a test as a dependent of the component it covers. New `f3-test-coverage` fixture + `GoldenCoverage`/`coverage` check kind (covers UserList, Sidebar untested). 6 parser unit tests (incl. two bundle-level); eval 262/0/0, gate OK. -### [ ] 5.5 Response-schema linking +### [x] 5.5 Response-schema linking **Failure modes:** F4 **Build:** data sources link to response types: generic argument (`useQuery`), annotated variable types, or an OpenAPI spec (scan option `openapi: path`) matched by endpoint pattern. Bundle lineage entries carry `responseType: { name, fields }` (one level of fields, not deep). **Accept:** fixture `f4-typed-responses` green for all three sources (generic, annotation, OpenAPI). +**Done:** `ResponseType { name, fields: {name,type}[], source }` on `DataSourceNode` in core (schema regenerated, drift gate green). New `response.ts` parser module: `responseFromCall` recovers the type from a call's generic argument (`axios.get`, `useQuery`) or, failing that, the annotation on the nearest enclosing typed variable whose initializer holds the call (`const data: Invoice[] = await fetch(…).then(r => r.json())`), stopping at function boundaries; only property signatures are read (one level, methods skipped). `loadOpenApi`/`linkOpenApiResponses` is a post-pass that fills untyped sources from an OpenAPI 3 JSON spec (`openapi` scan option / CLI `--openapi`), matching `${METHOD} ${endpoint}` with `{id}`β†’`:id` normalization and `$ref` resolution. Bundle lineage `dataSources` carry `responseType`; `trace` prints it. New `f4-typed-responses` fixture + `GoldenResponse`/`responses` check kind (all three sources). 5 parser unit tests incl. bundle-level; eval 265/0/0, gate OK. -### [ ] 5.6 Git history context +### [x] 5.6 Git history context **Failure modes:** F5 **Build:** `history` section: last N commits touching matched files (`git log --follow`), PR numbers parsed from merge/squash subjects. Pure `git` subprocess, no network. **Accept:** integration test on this repo's own history; graceful empty section outside a git repo. +**Done:** `gitHistory(root, files, limit)` in agent-sdk β€” per-file `git log --follow` (via `execFileSync`, no network), commits deduped across files and sorted newest-first by committer time, `parsePrNumber` pulls `#NN` from merge/squash subjects. Any failure (not a repo, git missing, untracked path) yields `[]` β€” the bundle degrades gracefully. Bundle `history` section (default 5) is populated from the matched component's files + render subtree; `BundleCommit` gains optional `pr` (context-bundle schema regenerated, drift gate green). Integration tests run against this repo's own history; 5 agent-sdk unit tests + a bundle-wiring test. eval 265/0/0, gate OK. -### [ ] 5.7 MCP server +### [x] 5.7 MCP server **Failure modes:** G1 (surface), pipeline integration **Build:** `@coderadar/mcp` exposing tools: `resolve_context(ticket)`, `find_component(terms)`, `trace_lineage(id)`, `journeys(id, depth)`, `blast_radius(id)` β€” thin wrappers over agent-sdk against a pre-built graph (path from env/config). Tool descriptions written for agent consumption (when to use which, what `ambiguous` means, that `disambiguation` should be relayed to a human). **Accept:** MCP integration test via stdio client: scan fixture β†’ each tool returns schema-valid envelopes; `ambiguous`/`declined` statuses round-trip. **Gate 5 passes.** *CodeRadar is now pluggable into the multi-agent system.* +**Done:** new `@coderadar/mcp` package (MCP SDK 1.29). `createServer(graph)` registers all five tools over `@modelcontextprotocol/sdk`'s `McpServer` with zod input schemas and agent-facing descriptions (`resolve_context` returns the full budgeted `ContextBundle`; each tool returns its QueryResult envelope as JSON text; descriptions state that `ambiguous`/`disambiguation` goes to a human). `resolve_context` = `buildBundle`; `find_component`/`trace_lineage`/`journeys`/`blast_radius` wrap core. Bin `coderadar-mcp` loads a graph from `CODERADAR_GRAPH` (or argv) and serves stdio; also shipped in the published `ui-lineage` bundle as the `ui-lineage-mcp` bin (MCP SDK + zod kept external). Integration test via a real stdio `Client`: lists all five tools and round-trips `ok`/`ambiguous`/`declined` over `a4-generic-text` (6 tests). **Gate 5 passed. M5 reached β€” ticket in β†’ budgeted context bundle out, over MCP.** --- @@ -329,6 +340,210 @@ The heart of the project. C1 and B1 live here. --- +## Phase 6F β€” Field hardening (feedback round 1) + +Source: external validation of `ui-lineage@0.3.0` (MCP id `coderadar`) against a production +React 18 + Redux Toolkit + RTK Query + MUI + React Router app (664 components, 1,982 instances, +4,843 edges), 2026-07-15. Verdict: `trace_lineage`, determinism, out-of-domain decline, token +budgeting all solid β€” but instance resolution hit 28%, RTK Query and object-config routes +produced zero nodes, and a matcher bug made `find_component` non-discriminating, **all while +every gate scores 1.000**. The eval suite has a fixture-vs-reality blind spot; this phase fixes +the field defects and closes that blind spot. Prioritized ahead of 6.1–6.5. + +### [x] 6F.1 Punctuation-wildcard matcher fix + no-signal decline +**Failure modes:** A14 (new β€” added to the catalog in this step), A4, D2, D6 +**Build:** rendered-text targets that normalize to empty (`|`, `/`, `:`, `-`) currently match +every query β€” `find_component(["zzqwxnomatch12345"])` "matches rendered text", and every call +returns the same ~20 punctuation-rendering components ranked only by the query's own character +rarity. Fix in the matcher: discard rendered-text targets that normalize to empty; require a +minimum alphanumeric token length before a rendered-text target can score. Add a `no-signal` +decline: when the only surviving matches are empty/punctuation targets, return `declined` +(reason `no-signal`) instead of `ambiguous` with a full candidate list. +**Accept:** new fixture `a14-punctuation-wildcard` (components rendering bare `|` / `/` / `-`): +gibberish query β†’ `declined: no-signal`; a real query ranks the true component top-1 with +punctuation components scoring 0; `resolveContext` no longer reports high confidence against +punctuation-only matches. +**Done:** root cause was `textMatches` (core text.ts): an empty haystack β€” rendered text like +`|` normalizes to `""` β€” satisfies `needle.includes(haystack)` for every query, and a pure-`*` +template collapses to a match-everything regex the same way. New `hasMatchSignal(normalized)` +guard (β‰₯ 2 alphanumeric chars, wildcards/spaces excluded) short-circuits `textMatches`, so +punctuation-only and pure-wildcard targets never match anything; with no surviving targets, +gibberish falls through to the existing `declined("no-signal")` path (no separate decline +branch needed), and `resolveContext` inherits the fix through `matchComponents`. A14 added to +docs/failure-modes.md. New fixture `a14-punctuation-wildcard` (Divider `|`, Breadcrumb `/` `-`, +CalendarPanel): gibberish declines, a real term ranks CalendarPanel top-1 with no punctuation +noise, gibberish mixed into a real query doesn't poison it. 8 new core unit tests; eval +271/0/0, gate OK. + +### [x] 6F.2 Field-pattern eval fixture +**Failure modes:** D2 (the eval blind spot itself) +**Build:** new fixture `eval/fixtures/field-patterns` mirroring the shapes the field app used +and current fixtures miss: multi-hop aliased barrel chains (`index.ts` re-export β†’ tsconfig +`paths` alias β†’ `export { X as Y }`), `Loadable(lazy(() => import()))` pages, an RTK Query +store (`createApi` + `injectEndpoints` + `builder.query|mutation` split across files under +`store/api/`), object-config `createBrowserRouter([...])` assembled from separate route files, +and tests using a custom render wrapper. Goldens assert target behavior (resolution rate, +data-source/route/coverage counts); checks owned by 6F.3–6F.6 land in an explicit skip list and +are enabled by their steps, so `pnpm eval` stays green throughout. +**Accept:** fixture scans clean; 6F.1 checks green; skip list names the step that must enable +each remaining check. +**Done:** new fixture `eval/fixtures/field-patterns` (11 app files): two-hop barrel + rename +(`components/index.ts` β†’ `ui/index.ts` β†’ definition), `@ui`/`@store/*` tsconfig-path aliases, +`Loadable(lazy(() => import()))` page elements, route arrays declared in `routes.tsx`, +spread-composed, and passed to `createBrowserRouter` as an imported identifier, an RTK Query +store (`createApi` base + two `injectEndpoints` slices with string-form and object-form +`query` plus a `builder.mutation`), and a test rendering through a custom +`renderWithProviders` wrapper. The harness's native xfail is the skip list: 9 target checks +carry `expectedFail` naming the enabling step (6F.3: DataGrid instance count + blast radius Β· +6F.4: two endpoint attributions Β· 6F.5: three routes, one effect, one journey), and +unexpected-pass gating removes stale marks the moment a capability lands. checks.ts change: +xfail-marked attributions stay out of the lineage precision/recall tallies while marked, so a +known-missing capability doesn't depress global metrics. Scanning the fixture confirmed the +field diagnosis β€” the `@ui` alias import yields NO instance node for UsersPage's ``, +and 0 route/data-source/hook nodes exist. Already working in this shape (kept as passing +checks): relative two-hop barrel rename (InvoicesPage's Grid), covered-by through the custom +wrapper β€” so 6F.6 must find the actual field-breaking coverage variant and extend the fixture. +eval 280 pass / 0 fail / 9 xfail / 0 unexpected-pass, gate OK, all metrics 1.000. + +### [x] 6F.3 Instanceβ†’definition resolution hardening +**Failure modes:** A5, C1, F2, D4 +**Build:** the field run linked only 563/1,982 instances (28%) β€” barrel resolution passes unit +tests but real chains break it. Extend import resolution: tsconfig `paths` aliases, multi-hop +re-export chains, `export * from`, default-export renames, and +`Loadable(lazy(() => import()))` / `React.lazy` wrappers. Unresolved instances keep the +`incomplete` flag with the failing import path recorded as evidence. +**Accept:** field-patterns fixture instanceβ†’definition resolution β‰₯ 95%; `blastRadius` on a +shared component returns its true dependents (field regression: was 0); render graph connects +route roots to leaf instances; enable the 6F.2 checks. +**Done:** two scanner changes. (1) `scanReact` now honors the scanned app's own +`tsconfig.json` (passed as `tsConfigFilePath`, file discovery still ours) so `baseUrl`/`paths` +make alias imports resolvable β€” `@ui` β†’ two-hop rename barrel β†’ definition now links, where +before the usage produced no instance node at all. (2) New `lazyDefinition` resolution in +`materializeInstances`: a tag naming a module-scope +`const X = Loadable(lazy(() => import("./Page")))` (or bare `React.lazy`) unwraps to the +dynamic import and resolves the target module's default export β€” exercised by a new +`PreviewPane` fixture component whose variable name differs from the definition, so only the +unwrap can link it. Fixture golden: 6F.3 marks removed (DataGrid instances = 2, blast radius +DataGrid β†’ UsersPage + InvoicesPage), InvoicesPage now has 1 instance (PreviewPane's lazy +usage). 100% of the fixture's component usages resolve. 2 new parser unit tests (121 total); +eval 283 pass / 0 fail / 7 xfail (all owned by 6F.4–6F.5) / 0 unexpected-pass, gate OK, +metrics 1.000. + +### [x] 6F.4 RTK Query extractor +**Failure modes:** B2, C5, C6 +**Build:** `createApi` / `injectEndpoints` / `builder.query|mutation` are unrecognized today β€” +0 data-source nodes across ~40 store files in the field run, so the entire server-cache +dimension is invisible. Recognize API-slice endpoint definitions as `DataSourceNode`s (URL from +the `query` builder, method, tags), link generated hooks (`useGetUsersQuery`) at component call +sites β†’ per-instance `fetches-from` edges, and model server-cache selectors +(`useSelector` on the api reducer path) as `StateNode` reads. +**Accept:** field-patterns fixture: every endpoint under `store/api/**` emits a data-source +node; `traceLineage` from a component using a generated hook reaches its endpoint; enable the +6F.2 checks. +**Done:** new `rtk-query` DataSourceKind in core (schema regenerated, drift gate green). In +`detectStores`, `createApi` / `api.injectEndpoints` declarations now extract endpoints: +`rtkBaseCall` resolves the (possibly imported) receiver back to its `createApi` through +chained `injectEndpoints` hops, `rtkBaseUrl` reads the `fetchBaseQuery` baseUrl, and every +`builder.query|mutation` becomes a DataSourceNode β€” string-form and object-form (`{ url, +method }`) query configs, template params normalized to `:param` (e.g. +`/api/invoices/:id/pay`, POST), baseUrl joined. Generated hooks (`useXQuery`, `useXMutation`, +`useLazyXQuery`) register in a new `rtkHooks` map, and the component body walk wires +`fetches-from` edges at call sites. Fixture: 3 data sources, both attribution xfails removed +and passing (UsersPage β†’ /api/users, InvoicesPage β†’ /api/invoices); the unused mutation stays +a consumer-less source. Server-cache `useSelector` reads remain future work (not needed by +the accept criteria). 4 new parser tests (125 total); eval 285 pass / 0 fail / 5 xfail (all +6F.5) / 0 unexpected-pass, gate OK, metrics 1.000. + +### [x] 6F.5 Object-config React Router recognition +**Failure modes:** B4, B3 +**Build:** step 3.1 covers `createBrowserRouter` on fixtures, but the field app produced 0 +route nodes β€” diagnose and close the gap: route-object arrays defined in separate +files/variables (not inline at the call site), spread/composed arrays, `children` nesting, +the `lazy:` route property, and `Loadable(lazy())` page `element`s. `routes-to` edges must +survive the lazy wrapper (depends on 6F.3). +**Accept:** field-patterns fixture: all golden routes emit `RouteNode`s; `journeys("/route")` +returns a real multi-step path (field regression: was `declined: not-found`); enable the 6F.2 +checks. +**Done:** three changes in routes.ts. (1) New `routeArrayElements`: a router config resolves +whether it's an array literal, an imported identifier (via go-to-definition), or +spread-composed from separately-declared arrays β€” applied to both the `createBrowserRouter` +argument and every `children` property, `as`/`satisfies` unwrapped, hop-bounded. (2) +`resolveLazyVariable` now unwraps wrapper helpers around the lazy call β€” +`Loadable(lazy(() => import()))` resolves to the imported page's default export, so +`routes-to` lands on the real component. (3) No change needed for `navigates-to`/journeys: +they lit up as soon as route nodes existed. Field-patterns golden: all 5 remaining xfails +removed β€” 3 routes, the onClickβ†’/users effect, and the "/"β†’"/users" terminal journey all pass +unmarked, so **the fixture is fully green with zero marks and Gate 6F's extractor criteria +are met**. 4 new parser tests (129 total); eval 290 pass / 0 fail / 0 xfail / 0 +unexpected-pass, gate OK, metrics 1.000. + +### [ ] 6F.6 Test-coverage detection hardening +**Failure modes:** F3 +**Build:** the field run found 1 `covered-by` edge app-wide, making `untested` warnings +near-universal noise. Handle custom render wrappers (`renderWithProviders()` β€” resolve +through to the JSX argument), test files importing through the same alias/barrel chains as +6F.3, and suites living outside `__tests__`. When coverage mapping is still near-empty +(< 5% of components covered), replace per-bundle `untested` warnings with a single graph-level +`coverage-unmapped` note. +**Accept:** field-patterns fixture: wrapped renders produce `covered-by` edges; a near-empty +coverage graph emits `coverage-unmapped` instead of per-component `untested`; enable the 6F.2 +checks. + +### [x] 6F.7 Scoring & result-surface polish +**Failure modes:** A4, D2, D6 +**Build:** weight matches by term specificity against the candidate's own identifiers (name, +props, file path), not global character rarity alone β€” gibberish must never outscore a real +term (field: 6.50 for gibberish vs 6.09 for "calendar"). Expose a top-line `score` alongside +`confidence` on every `find_component` / `resolve_context` candidate (currently nested and easy +to misread) β€” core envelope, CLI output, MCP result schema. +**Accept:** ranking fixture: identifier-specific terms beat rarity-only scores; `score` present +at candidate top level across CLI + MCP (schemas regenerated, drift gate green). +**Done:** `matchComponents` applies `IDENTIFIER_AFFINITY` (Γ—1.5) to a matched term that also +names the candidate β€” its name, props, or file basename, camelCase-split and tokenized +(memoized per component, fuzzy-tolerant) β€” with the evidence line noting "(also names the +component)". Genuinely-tied generic terms stay tied, so ambiguity honesty is unchanged +(1.000). `Candidate` gains an optional top-level `score` (raw ranking score, larger = stronger +within one result; `confidence` stays the calibrated signal) set by `matchComponents` and +printed by the CLI (`score=… confidence=…`); MCP inherits it through the envelope JSON. +Candidate isn't part of the generated schemas, so no schema change (drift gate confirms). +2 new core tests (211 total); eval 290/0/0/0, gate OK, metrics 1.000. + +### [x] 6F.8 Galaxy visualizer (`coderadar visualize`) +**Failure modes:** β€” (user-requested feature, 2026-07-15) +**Build:** new CLI command `visualize -g -o ` emitting a single +self-contained HTML file (graph JSON + all JS/CSS inlined, zero network dependencies) that +renders the lineage graph as a canvas force-directed "galaxy": node kinds color-coded +(component / instance / hook / data-source / state / event / route / external), edges typed. +Interactions: kind + edge-type filter toggles, search with fly-to, click β†’ detail panel (name, +file, loc, props) with neighborhood highlight (visual blast radius) and dim-others, zoom/pan, +physics pause. Must stay responsive at field scale (~2.6k nodes / ~4.8k edges). +**Accept:** generated from the demo-app graph: opens from `file://` with no external requests, +all node kinds rendered and filterable; unit tests on generation (embedded JSON round-trips, +output is self-contained); README section with a screenshot. +**Done:** new `visualize.ts` in the CLI package β€” pure `renderVisualization(graph)` (graph in, +HTML string out, trivially testable) plus a `toViewModel` that trims nodes to what the client +needs and drops dangling edges. The command `ui-lineage visualize -g -o ` writes +one self-contained file: graph JSON embedded (with `<` β†’ `<` so rendered text containing +`` can't break out), all CSS/JS inlined, no network requests. Client is a canvas +force-directed sim with grid-approximated repulsion (neighbor-cell only) so it scales to +thousands of nodes; node radius scales with degree; kinds color-coded with a legend. +Interactions: per-kind node + edge filter toggles (with all/none), search-and-fly, click β†’ +detail panel (name, kind, detail line, file:line, connection count, flags) with neighbor +highlight + dim-others (visual blast radius), drag-to-pin, scroll-zoom, physics pause, +always-labels. Verified live in-browser against the demo-app graph: renders, filters, physics +toggle, and searchβ†’select all work with zero console errors; `file://`/`http` both load with +no external requests. CLI package gained a `test` script + vitest; 5 new tests (embed +round-trips, dangling-edge drop, `` breakout guard, self-contained assertions). +README documents the command. eval unaffected (290/0/0/0). **Gate 6F extractor criteria met; +only 6F.6 (coverage, data-blocked) remains before the gate fully closes.** + +**Gate 6F:** field-patterns fixture fully green (skip list empty βœ…) Β· instance resolution β‰₯ 95% +(βœ… 100%) Β· RTK data sources > 0 (βœ…) Β· route nodes > 0 (βœ…) Β· gibberish queries decline +`no-signal` (βœ…) Β· `pnpm eval` green end-to-end (βœ… 290/0/0/0). Remaining before the gate is +formally stamped: 6F.6 test-coverage hardening (blocked on a real failing sample). + +--- + ## Phase 7 β€” Backend parsers & federation (v2 horizon) Sketch level β€” detail before starting the phase, after v1 feedback. @@ -358,6 +573,7 @@ Sketch level β€” detail before starting the phase, after v1 feedback. | M2 | **Headline correct:** per-instance API attribution (C1) + handler resolution (B1) | 2 | | M3 | n-level journeys, lazily expanded | 3 | | M4 | Screenshot/text β†’ ranked, calibrated, honest matches | 4 | -| M5 | **Pluggable node:** ticket in β†’ budgeted context bundle out, over MCP | 5 | +| M5 βœ… | **Pluggable node:** ticket in β†’ budgeted context bundle out, over MCP | 5 | +| M6F | Field-hardened: v0.3.0 feedback closed β€” trustworthy matching + real-world extractors + visualizer | 6F | | M6 | Production-grade: incremental, fast, deterministic, versioned | 6 | | M7 | Full-stack lineage: pixel β†’ backend handler | 7 | diff --git a/docs/failure-modes.md b/docs/failure-modes.md index 4248649..8a7c009 100644 --- a/docs/failure-modes.md +++ b/docs/failure-modes.md @@ -30,6 +30,7 @@ every downstream agent. When in doubt, return ranked candidates with evidence, o | A11 | **Version drift** β€” screenshot from prod, graph from today's main | Component renamed/removed since | SHA-tagged graphs; report renames across graph versions | 6 | | A12 | **Non-text UI** β€” charts, icons, canvases | Nothing to text-match | Graceful degradation: structural candidates + explicit low confidence | 4 | | A13 | **Third-party embeds** β€” Intercom, iframes | Content isn't ours | Detect and report "not in this codebase" | 4 | +| A14 | **Empty-normalizing rendered text** β€” components rendering bare `\|` / `/` / `-` | Normalization strips punctuation β†’ empty target treated as a substring of every query; matcher becomes a universal wildcard (field-found in v0.3.0) | Discard targets that normalize to empty; minimum alphanumeric token length; `no-signal` decline when only such targets match | 6F | ## B. User-journey extraction 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/calibration.json b/eval/calibration.json new file mode 100644 index 0000000..f5c81c6 --- /dev/null +++ b/eval/calibration.json @@ -0,0 +1,11 @@ +{ + "generatedAt": "2026-07-14T21:10:13.631Z", + "thresholds": { + "high": 0.8, + "medium": 0.5 + }, + "measuredPrecisionAtHigh": 1, + "empiricalHighFloor": 1, + "sampleSize": 71, + "note": "The matcher's confidenceFromScore uses these thresholds. measuredPrecisionAtHigh is the fraction of high-confidence eval answers that are correct; empiricalHighFloor is the lowest score at which precision still holds >= 0.95." +} 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 ( +
+ + +
+ + ); +} 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/fixtures/a3-api-text/app/ApiForm.tsx b/eval/fixtures/a3-api-text/app/ApiForm.tsx new file mode 100644 index 0000000..fe56937 --- /dev/null +++ b/eval/fixtures/a3-api-text/app/ApiForm.tsx @@ -0,0 +1,10 @@ +export function ApiForm({ onSubmit }: { onSubmit: () => void }) { + // Labels/placeholders are all CMS-driven β€” no literals in source. + return ( +
+ + +
+ + ); +} 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/a5-design-system/app/MembersPage.tsx b/eval/fixtures/a5-design-system/app/MembersPage.tsx new file mode 100644 index 0000000..989352c --- /dev/null +++ b/eval/fixtures/a5-design-system/app/MembersPage.tsx @@ -0,0 +1,11 @@ +import { Avatar, ProfileCard } from "./ui"; + +export function MembersPage() { + return ( +
+

Members directory

+ + +
+ ); +} diff --git a/eval/fixtures/a5-design-system/app/SettingsPage.tsx b/eval/fixtures/a5-design-system/app/SettingsPage.tsx new file mode 100644 index 0000000..0edcd52 --- /dev/null +++ b/eval/fixtures/a5-design-system/app/SettingsPage.tsx @@ -0,0 +1,11 @@ +import { Button, DataGrid } from "@acme/ui"; + +export function SettingsPage() { + return ( +
+

Workspace settings

+ +
+ ); +} diff --git a/eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx b/eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx new file mode 100644 index 0000000..aebc623 --- /dev/null +++ b/eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx @@ -0,0 +1,12 @@ +export function ProfileCardInner({ name }: { name: string }) { + return ( +
+

Profile details

+ {name} +
+ ); +} + +export default function AvatarBadge() { + return Member avatar; +} diff --git a/eval/fixtures/a5-design-system/app/ui/index.ts b/eval/fixtures/a5-design-system/app/ui/index.ts new file mode 100644 index 0000000..70d85c6 --- /dev/null +++ b/eval/fixtures/a5-design-system/app/ui/index.ts @@ -0,0 +1,2 @@ +export { ProfileCardInner as ProfileCard } from "./ProfileCard"; +export { default as Avatar } from "./ProfileCard"; diff --git a/eval/fixtures/a5-design-system/golden.json b/eval/fixtures/a5-design-system/golden.json new file mode 100644 index 0000000..3362be7 --- /dev/null +++ b/eval/fixtures/a5-design-system/golden.json @@ -0,0 +1,21 @@ +{ + "failureMode": "A5", + "note": "Design-system components: } + {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/fixtures/a9-modal-portal/app/ConfirmDialog.tsx b/eval/fixtures/a9-modal-portal/app/ConfirmDialog.tsx new file mode 100644 index 0000000..01a7dcc --- /dev/null +++ b/eval/fixtures/a9-modal-portal/app/ConfirmDialog.tsx @@ -0,0 +1,19 @@ +import { createPortal } from "react-dom"; + +export function ConfirmDialog({ + open, + onConfirm, +}: { + open: boolean; + onConfirm: () => void; +}) { + if (!open) return null; + return createPortal( +
+

Delete this order?

+

This cannot be undone.

+ +
, + document.body, + ); +} diff --git a/eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx b/eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx new file mode 100644 index 0000000..fe05e79 --- /dev/null +++ b/eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; +import { toast } from "react-hot-toast"; + +import { ConfirmDialog } from "./ConfirmDialog"; + +export function OrdersToolbar() { + const [confirming, setConfirming] = useState(false); + + const handleConfirm = () => { + fetch("/api/orders/selected", { method: "DELETE" }); + setConfirming(false); + toast("Order deleted"); + }; + + return ( +
+ + +
+ ); +} diff --git a/eval/fixtures/a9-modal-portal/golden.json b/eval/fixtures/a9-modal-portal/golden.json new file mode 100644 index 0000000..fa313c9 --- /dev/null +++ b/eval/fixtures/a9-modal-portal/golden.json @@ -0,0 +1,25 @@ +{ + "failureMode": "A9", + "note": "Portals & toasts: the dialog renders into document.body via createPortal β€” a screenshot of it shows nothing from its trigger's DOM subtree. Matching the modal text must surface ConfirmDialog (whose instance list points at the OrdersToolbar trigger site). Toast text ('Order deleted') exists only as a call argument; it must match the CALLING component.", + "expect": { + "components": [ + { "name": "ConfirmDialog", "instances": 1 }, + { "name": "OrdersToolbar", "instances": 0 } + ], + "attributions": [ + { "component": "OrdersToolbar", "endpoints": ["/api/orders/selected"] }, + { + "component": "ConfirmDialog", + "instanceAt": "OrdersToolbar.tsx", + "endpoints": ["/api/orders/selected"], + "_note": "not data-in: the dialog's confirm button TRIGGERS the DELETE via the resolved handler chain (2.3) β€” correct effect lineage" + } + ], + "queries": [ + { "terms": ["Delete this order?"], "status": "ok", "top": "ConfirmDialog" }, + { "terms": ["Confirm delete"], "status": "ok", "top": "ConfirmDialog" }, + { "terms": ["Order deleted"], "status": "ok", "top": "OrdersToolbar" }, + { "terms": ["Delete order"], "status": "ok", "top": "OrdersToolbar" } + ] + } +} diff --git a/eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx b/eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx new file mode 100644 index 0000000..dc9113a --- /dev/null +++ b/eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx @@ -0,0 +1,14 @@ +import { EditorPanel } from "./EditorPanel"; + +export function DraftEditor() { + const persistDraft = () => { + fetch("/api/drafts", { method: "POST", body: "{}" }); + }; + + return ( +
+

Draft editor

+ +
+ ); +} diff --git a/eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx b/eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx new file mode 100644 index 0000000..7f1e184 --- /dev/null +++ b/eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx @@ -0,0 +1,11 @@ +import { Toolbar } from "./Toolbar"; + +/** Inline-arrow hop: the prop is wrapped, not passed directly. */ +export function EditorPanel({ onPersist }: { onPersist: () => void }) { + return ( +
+

Editor tools

+ onPersist()} /> +
+ ); +} diff --git a/eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx b/eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx new file mode 100644 index 0000000..6378e1b --- /dev/null +++ b/eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx @@ -0,0 +1,17 @@ +export function OrphanButton({ onMystery }: { onMystery?: () => void }) { + return ( + + ); +} + +/** Renders OrphanButton without ever passing the handler β€” must flag, not guess. */ +export function OrphanHost() { + return ( +
+

Orphan host

+ +
+ ); +} diff --git a/eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx b/eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx new file mode 100644 index 0000000..7e6b183 --- /dev/null +++ b/eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx @@ -0,0 +1,7 @@ +export function SaveButton({ onSave }: { onSave: () => void }) { + return ( + + ); +} diff --git a/eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx b/eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx new file mode 100644 index 0000000..4e79469 --- /dev/null +++ b/eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx @@ -0,0 +1,10 @@ +import { SaveButton } from "./SaveButton"; + +/** Renamed hop: the prop arrives as onSaveDraft, is passed down as onSave. */ +export function Toolbar({ onSaveDraft }: { onSaveDraft: () => void }) { + return ( +
+ +
+ ); +} diff --git a/eval/fixtures/b1-prop-drilled-handler/golden.json b/eval/fixtures/b1-prop-drilled-handler/golden.json new file mode 100644 index 0000000..f6c752f --- /dev/null +++ b/eval/fixtures/b1-prop-drilled-handler/golden.json @@ -0,0 +1,29 @@ +{ + "failureMode": "B1", + "note": "Callback prop-drilling, 4 levels: SaveButton.onClick={onSave} <- Toolbar (renamed prop) <- EditorPanel (inline arrow wrap) <- DraftEditor (real handler: fetch POST /api/drafts). Per-file analysis stops at the prop boundary; chain resolution must ground the event in the fetch. OrphanButton's never-passed handler must flag unresolved, not guess.", + "expect": { + "components": [ + { "name": "SaveButton", "instances": 1 }, + { "name": "Toolbar", "instances": 1 }, + { "name": "EditorPanel", "instances": 1 }, + { "name": "DraftEditor", "instances": 0 }, + { "name": "OrphanButton", "instances": 1 } + ], + "attributions": [ + { "component": "SaveButton", "endpoints": ["/api/drafts"] }, + { "component": "OrphanButton", "endpoints": [] } + ], + "forbidden": [ + { + "component": "OrphanButton", + "endpoint": "/api/drafts", + "note": "poison: an unrelated chain's endpoint attributed to the orphan" + } + ], + "queries": [ + { "terms": ["Save draft"], "status": "ok", "top": "SaveButton" }, + { "terms": ["Draft editor"], "status": "ok", "top": "DraftEditor" }, + { "terms": ["Mystery action"], "status": "ok", "top": "OrphanButton" } + ] + } +} diff --git a/eval/fixtures/b3-programmatic-nav/app/routes.tsx b/eval/fixtures/b3-programmatic-nav/app/routes.tsx new file mode 100644 index 0000000..7c6e128 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/routes.tsx @@ -0,0 +1,13 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { CartPage } from "./screens/CartPage"; +import { CheckoutPage } from "./screens/CheckoutPage"; +import { UserDetail } from "./screens/UserDetail"; +import { UsersPage } from "./screens/UsersPage"; + +export const router = createBrowserRouter([ + { path: "/users", element: }, + { path: "/users/:userId", element: }, + { path: "/cart", element: }, + { path: "/checkout", element: }, +]); diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx new file mode 100644 index 0000000..89622ea --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; +import { useDispatch } from "react-redux"; + +import { clearCart } from "../store/cartSlice"; + +export function CartPage() { + const dispatch = useDispatch(); + const [open, setOpen] = useState(false); + + // Local setter β†’ writes-state on the local useState slot. + const showDetails = () => setOpen(true); + + return ( +
+

Your cart

+ {/* Inline dispatch of a plain action β†’ writes-state on the cart slice. */} + + + {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/fixtures/b4-nextjs-approuter/app/app/(marketing)/pricing/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/(marketing)/pricing/page.tsx new file mode 100644 index 0000000..b038ec4 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/(marketing)/pricing/page.tsx @@ -0,0 +1,3 @@ +export default function PricingPage() { + return
Simple, transparent pricing
; +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/layout.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/layout.tsx new file mode 100644 index 0000000..5142a83 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/layout.tsx @@ -0,0 +1,8 @@ +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx new file mode 100644 index 0000000..64996d4 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export default function DashboardPage() { + const [metrics, setMetrics] = useState([]); + useEffect(() => { + fetch("/api/metrics").then((r) => r.json()).then(setMetrics); + }, []); + return ( +
+

Key metrics

+
{metrics.length}
+
+ ); +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/docs/[...slug]/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/docs/[...slug]/page.tsx new file mode 100644 index 0000000..e56a1fe --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/docs/[...slug]/page.tsx @@ -0,0 +1,3 @@ +export default function DocsPage() { + return
Documentation chapter
; +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx new file mode 100644 index 0000000..8f5e1f2 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx @@ -0,0 +1,10 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
Nimbus Analytics
+ {children} + + + ); +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/page.tsx new file mode 100644 index 0000000..eff4f9d --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/page.tsx @@ -0,0 +1,3 @@ +export default function HomePage() { + return

Your analytics at a glance

; +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/users/[userId]/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/users/[userId]/page.tsx new file mode 100644 index 0000000..ba0c4fa --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/app/users/[userId]/page.tsx @@ -0,0 +1,3 @@ +export default function UserProfilePage() { + return
Member profile and activity
; +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/next.config.mjs b/eval/fixtures/b4-nextjs-approuter/app/next.config.mjs new file mode 100644 index 0000000..8cfba81 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/next.config.mjs @@ -0,0 +1,2 @@ +/** Marks this fixture as a Next.js project for router detection. */ +export default {}; diff --git a/eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx b/eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx new file mode 100644 index 0000000..c8492c1 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx @@ -0,0 +1,3 @@ +export default function MyApp({ Component, pageProps }: { Component: React.ComponentType; pageProps: Record }) { + return ; +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts b/eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts new file mode 100644 index 0000000..a0dac74 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts @@ -0,0 +1,4 @@ +// API route β€” must never become a page route. +export default function handler(_req: unknown, res: { status: (code: number) => { json: (body: unknown) => void } }) { + res.status(200).json({ ok: true }); +} diff --git a/eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx b/eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx new file mode 100644 index 0000000..87436e0 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx @@ -0,0 +1,4 @@ +// Pages-router leftover in an app-router project β€” both must be scanned. +export default function LegacyDetailPage() { + return
Legacy record viewer
; +} diff --git a/eval/fixtures/b4-nextjs-approuter/golden.json b/eval/fixtures/b4-nextjs-approuter/golden.json new file mode 100644 index 0000000..19b6af5 --- /dev/null +++ b/eval/fixtures/b4-nextjs-approuter/golden.json @@ -0,0 +1,22 @@ +{ + "failureMode": "B4", + "note": "Next.js file-based routing: app router ([param] and [...catchAll] segments, (group) dirs dropped, nearest layout.tsx wins) plus a pages-router leftover. _app and pages/api/** must never become routes.", + "expect": { + "routes": [ + { "path": "/", "component": "HomePage", "layout": "RootLayout", "guards": [] }, + { "path": "/dashboard", "component": "DashboardPage", "layout": "DashboardLayout", "guards": [] }, + { "path": "/pricing", "component": "PricingPage", "layout": "RootLayout", "guards": [] }, + { "path": "/users/:userId", "component": "UserProfilePage", "layout": "RootLayout", "guards": [] }, + { "path": "/docs/:slug*", "component": "DocsPage", "layout": "RootLayout", "guards": [] }, + { "path": "/legacy/:id", "component": "LegacyDetailPage", "guards": [] } + ], + "forbiddenRoutes": ["/api/health", "/_app"], + "attributions": [ + { "component": "DashboardPage", "endpoints": ["/api/metrics"] } + ], + "queries": [ + { "terms": ["Key metrics"], "status": "ok", "top": "DashboardPage" }, + { "terms": ["transparent pricing"], "status": "ok", "top": "PricingPage" } + ] + } +} diff --git a/eval/fixtures/b4-react-router/app/ReportsApp.tsx b/eval/fixtures/b4-react-router/app/ReportsApp.tsx new file mode 100644 index 0000000..9363b3d --- /dev/null +++ b/eval/fixtures/b4-react-router/app/ReportsApp.tsx @@ -0,0 +1,17 @@ +import { Route, Routes } from "react-router-dom"; + +import { ReportsLayout } from "./ReportsLayout"; +import { ReportDetail } from "./pages/ReportDetail"; +import { ReportsHome } from "./pages/ReportsHome"; + +/** JSX-declared route tree β€” the second React Router declaration style. */ +export function ReportsApp() { + return ( + + }> + } /> + } /> + + + ); +} diff --git a/eval/fixtures/b4-react-router/app/ReportsLayout.tsx b/eval/fixtures/b4-react-router/app/ReportsLayout.tsx new file mode 100644 index 0000000..c98ef8c --- /dev/null +++ b/eval/fixtures/b4-react-router/app/ReportsLayout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "react-router-dom"; + +export function ReportsLayout() { + return ( +
+

Reporting

+ +
+ ); +} diff --git a/eval/fixtures/b4-react-router/app/RequireAuth.tsx b/eval/fixtures/b4-react-router/app/RequireAuth.tsx new file mode 100644 index 0000000..36a713d --- /dev/null +++ b/eval/fixtures/b4-react-router/app/RequireAuth.tsx @@ -0,0 +1,10 @@ +import { Outlet, useLocation } from "react-router-dom"; + +export function RequireAuth({ children }: { children?: unknown }) { + const location = useLocation(); + const authed = Boolean(location); + if (!authed) { + return

Please sign in to continue

; + } + return children !== undefined ? <>{children} : ; +} diff --git a/eval/fixtures/b4-react-router/app/RootLayout.tsx b/eval/fixtures/b4-react-router/app/RootLayout.tsx new file mode 100644 index 0000000..437249c --- /dev/null +++ b/eval/fixtures/b4-react-router/app/RootLayout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "react-router-dom"; + +export function RootLayout() { + return ( +
+ + +
+ ); +} diff --git a/eval/fixtures/b4-react-router/app/main.tsx b/eval/fixtures/b4-react-router/app/main.tsx new file mode 100644 index 0000000..0dd64c9 --- /dev/null +++ b/eval/fixtures/b4-react-router/app/main.tsx @@ -0,0 +1,30 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { RequireAuth } from "./RequireAuth"; +import { RootLayout } from "./RootLayout"; +import { AdminPanel } from "./pages/AdminPanel"; +import { AuditLog } from "./pages/AuditLog"; +import { HomePage } from "./pages/HomePage"; +import { UserDetail } from "./pages/UserDetail"; +import { UsersPage } from "./pages/UsersPage"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "users", element: }, + { path: "users/:userId", element: }, + { + // Pathless guard route: everything below requires auth. + element: , + children: [{ path: "admin", element: }], + }, + // Guard wrapping the element inline instead of via a parent route. + { path: "audit", element: }, + // Lazy route module (react-router data-router convention). + { path: "settings", lazy: () => import("./pages/SettingsPage") }, + ], + }, +]); diff --git a/eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx b/eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx new file mode 100644 index 0000000..6a50568 --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx @@ -0,0 +1,3 @@ +export function AdminPanel() { + return
Admin control panel
; +} diff --git a/eval/fixtures/b4-react-router/app/pages/AuditLog.tsx b/eval/fixtures/b4-react-router/app/pages/AuditLog.tsx new file mode 100644 index 0000000..03b698b --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/AuditLog.tsx @@ -0,0 +1,3 @@ +export function AuditLog() { + return Audit trail entries
; +} diff --git a/eval/fixtures/b4-react-router/app/pages/HomePage.tsx b/eval/fixtures/b4-react-router/app/pages/HomePage.tsx new file mode 100644 index 0000000..9f051d0 --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/HomePage.tsx @@ -0,0 +1,3 @@ +export function HomePage() { + return

Welcome to Acme Console

; +} diff --git a/eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx b/eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx new file mode 100644 index 0000000..1d32e7e --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx @@ -0,0 +1,3 @@ +export function ReportDetail() { + return
Quarterly report breakdown
; +} diff --git a/eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx b/eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx new file mode 100644 index 0000000..720383d --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx @@ -0,0 +1,3 @@ +export function ReportsHome() { + return

Choose a report to view

; +} diff --git a/eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx b/eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx new file mode 100644 index 0000000..08851da --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx @@ -0,0 +1,6 @@ +export function SettingsPage() { + return
Workspace settings
; +} + +// react-router lazy-route convention: the module exports `Component`. +export { SettingsPage as Component }; diff --git a/eval/fixtures/b4-react-router/app/pages/UserDetail.tsx b/eval/fixtures/b4-react-router/app/pages/UserDetail.tsx new file mode 100644 index 0000000..24c5282 --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/UserDetail.tsx @@ -0,0 +1,3 @@ +export function UserDetail() { + return
User profile details
; +} diff --git a/eval/fixtures/b4-react-router/app/pages/UsersPage.tsx b/eval/fixtures/b4-react-router/app/pages/UsersPage.tsx new file mode 100644 index 0000000..8780f7a --- /dev/null +++ b/eval/fixtures/b4-react-router/app/pages/UsersPage.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export function UsersPage() { + const [users, setUsers] = useState([]); + useEffect(() => { + fetch("/api/users").then((r) => r.json()).then(setUsers); + }, []); + return ( +
+

All users

+
    {users.length}
+
+ ); +} diff --git a/eval/fixtures/b4-react-router/golden.json b/eval/fixtures/b4-react-router/golden.json new file mode 100644 index 0000000..4702a39 --- /dev/null +++ b/eval/fixtures/b4-react-router/golden.json @@ -0,0 +1,27 @@ +{ + "failureMode": "B4", + "note": "React Router adapter: createBrowserRouter object trees (nested children, index route, pathless guard route, inline guard wrapper, lazy route module) plus a JSX / tree. Every golden route must map to its page component with the right layout and guards.", + "expect": { + "routes": [ + { "path": "/", "component": "HomePage", "layout": "RootLayout", "guards": [] }, + { "path": "/users", "component": "UsersPage", "layout": "RootLayout", "guards": [] }, + { "path": "/users/:userId", "component": "UserDetail", "layout": "RootLayout", "guards": [] }, + { "path": "/admin", "component": "AdminPanel", "layout": "RootLayout", "guards": ["RequireAuth"] }, + { "path": "/audit", "component": "AuditLog", "layout": "RootLayout", "guards": ["RequireAuth"] }, + { "path": "/settings", "component": "SettingsPage", "layout": "RootLayout", "guards": [] }, + { "path": "/reports", "component": "ReportsHome", "layout": "ReportsLayout", "guards": [] }, + { "path": "/reports/:reportId", "component": "ReportDetail", "layout": "ReportsLayout", "guards": [] } + ], + "components": [ + { "name": "UsersPage", "instances": 0 }, + { "name": "ReportsHome", "instances": 1 } + ], + "attributions": [ + { "component": "UsersPage", "endpoints": ["/api/users"] } + ], + "queries": [ + { "terms": ["All users"], "status": "ok", "top": "UsersPage" }, + { "terms": ["Workspace settings"], "status": "ok", "top": "SettingsPage" } + ] + } +} diff --git a/eval/fixtures/b6-cyclic-journeys/app/routes.tsx b/eval/fixtures/b6-cyclic-journeys/app/routes.tsx new file mode 100644 index 0000000..3fbb0c0 --- /dev/null +++ b/eval/fixtures/b6-cyclic-journeys/app/routes.tsx @@ -0,0 +1,9 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { UserDetail } from "./screens/UserDetail"; +import { UsersList } from "./screens/UsersList"; + +export const router = createBrowserRouter([ + { path: "/users", element: }, + { path: "/users/:userId", element: }, +]); diff --git a/eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx b/eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx new file mode 100644 index 0000000..2c54379 --- /dev/null +++ b/eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx @@ -0,0 +1,17 @@ +import { useNavigate } from "react-router-dom"; + +export function UserDetail() { + const navigate = useNavigate(); + + // Navigate back to the list β€” the return edge that closes the cycle. + const back = () => navigate("/users"); + const reload = () => fetch("/api/users/current"); + + return ( +
+

User profile

+ + +
+ ); +} 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/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/eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx b/eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx new file mode 100644 index 0000000..aee0799 --- /dev/null +++ b/eval/fixtures/b9-cross-app-hops/app/CallbackPage.tsx @@ -0,0 +1,3 @@ +export function CallbackPage() { + return

Completing sign-in…

; +} diff --git a/eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx b/eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx new file mode 100644 index 0000000..33ade82 --- /dev/null +++ b/eval/fixtures/b9-cross-app-hops/app/CheckoutPage.tsx @@ -0,0 +1,11 @@ +export function CheckoutPage() { + // Payment gateway β€” an outbound hop to Stripe. + const pay = () => window.open("https://checkout.stripe.com/pay"); + + return ( +
+

Checkout

+ +
+ ); +} diff --git a/eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx b/eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx new file mode 100644 index 0000000..277aa53 --- /dev/null +++ b/eval/fixtures/b9-cross-app-hops/app/LoginPage.tsx @@ -0,0 +1,13 @@ +export function LoginPage() { + // OAuth redirect β€” the journey leaves the app for the provider. + const loginWithGoogle = () => + window.location.assign("https://accounts.google.com/o/oauth2/auth"); + + return ( +
+ ); +} diff --git a/eval/fixtures/b9-cross-app-hops/app/routes.tsx b/eval/fixtures/b9-cross-app-hops/app/routes.tsx new file mode 100644 index 0000000..281c7ec --- /dev/null +++ b/eval/fixtures/b9-cross-app-hops/app/routes.tsx @@ -0,0 +1,11 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { CallbackPage } from "./CallbackPage"; +import { CheckoutPage } from "./CheckoutPage"; +import { LoginPage } from "./LoginPage"; + +export const router = createBrowserRouter([ + { path: "/login", element: }, + { path: "/checkout", element: }, + { path: "/auth/callback", element: }, +]); diff --git a/eval/fixtures/b9-cross-app-hops/golden.json b/eval/fixtures/b9-cross-app-hops/golden.json new file mode 100644 index 0000000..5fcf214 --- /dev/null +++ b/eval/fixtures/b9-cross-app-hops/golden.json @@ -0,0 +1,22 @@ +{ + "failureMode": "B9", + "note": "Cross-app hops: an OAuth redirect (window.location.assign), a payment gateway (window.open), and a mailto link all leave the app β†’ exits-app edges to external destinations; an /auth/callback route is an inbound entry point β†’ an enters-at edge. Journeys that reach an external end with an 'exit' step.", + "expect": { + "components": [ + { "name": "LoginPage", "instances": 0 }, + { "name": "CheckoutPage", "instances": 0 }, + { "name": "CallbackPage", "instances": 0 } + ], + "routes": [ + { "path": "/login", "component": "LoginPage" }, + { "path": "/checkout", "component": "CheckoutPage" }, + { "path": "/auth/callback", "component": "CallbackPage" } + ], + "externals": [ + { "kind": "exits", "component": "LoginPage", "host": "accounts.google.com" }, + { "kind": "exits", "component": "LoginPage", "host": "mailto" }, + { "kind": "exits", "component": "CheckoutPage", "host": "checkout.stripe.com" }, + { "kind": "enters", "route": "/auth/callback", "host": "inbound" } + ] + } +} diff --git a/eval/fixtures/c1-prop-variants/app/HookPage.tsx b/eval/fixtures/c1-prop-variants/app/HookPage.tsx new file mode 100644 index 0000000..209b62d --- /dev/null +++ b/eval/fixtures/c1-prop-variants/app/HookPage.tsx @@ -0,0 +1,13 @@ +import { useMembers } from "./hooks/useMembers"; +import { Table } from "./Table"; + +export function HookPage() { + const { members } = useMembers(); + + return ( +
+

Member rows

+ + + ); +} diff --git a/eval/fixtures/c1-prop-variants/app/QueryPage.tsx b/eval/fixtures/c1-prop-variants/app/QueryPage.tsx new file mode 100644 index 0000000..5fee0fc --- /dev/null +++ b/eval/fixtures/c1-prop-variants/app/QueryPage.tsx @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchBilling } from "./api/billing"; +import { Table } from "./Table"; + +export function QueryPage() { + const { data } = useQuery({ queryKey: ["billing"], queryFn: fetchBilling }); + + return ( +
+

Billing rows

+
+ + ); +} diff --git a/eval/fixtures/c1-prop-variants/app/Table.tsx b/eval/fixtures/c1-prop-variants/app/Table.tsx new file mode 100644 index 0000000..49e02ba --- /dev/null +++ b/eval/fixtures/c1-prop-variants/app/Table.tsx @@ -0,0 +1,10 @@ +export function Table({ rows }: { rows: string[] }) { + if (rows.length === 0) return

Nothing to show

; + return ( +
    + {rows.map((r) => ( +
  • {r}
  • + ))} +
+ ); +} diff --git a/eval/fixtures/c1-prop-variants/app/api/billing.ts b/eval/fixtures/c1-prop-variants/app/api/billing.ts new file mode 100644 index 0000000..b069500 --- /dev/null +++ b/eval/fixtures/c1-prop-variants/app/api/billing.ts @@ -0,0 +1,4 @@ +export async function fetchBilling() { + const res = await fetch("/api/billing"); + return res.json(); +} diff --git a/eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts b/eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts new file mode 100644 index 0000000..36b368b --- /dev/null +++ b/eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export function useMembers() { + const [members, setMembers] = useState([]); + useEffect(() => { + fetch("/api/members") + .then((res) => res.json()) + .then(setMembers); + }, []); + return { members }; +} diff --git a/eval/fixtures/c1-prop-variants/golden.json b/eval/fixtures/c1-prop-variants/golden.json new file mode 100644 index 0000000..e9602b2 --- /dev/null +++ b/eval/fixtures/c1-prop-variants/golden.json @@ -0,0 +1,37 @@ +{ + "failureMode": "C1", + "note": "Prop-origin variants beyond the useState/useEffect pattern: a react-query result prop (with ?? [] derivation) and a custom-hook result prop. The same Table component must carry a different API per page.", + "expect": { + "components": [{ "name": "Table", "instances": 2 }], + "attributions": [ + { + "component": "Table", + "instanceAt": "QueryPage.tsx", + "endpoints": ["/api/billing"] + }, + { + "component": "Table", + "instanceAt": "HookPage.tsx", + "endpoints": ["/api/members"] + } + ], + "forbidden": [ + { + "component": "Table", + "instanceAt": "QueryPage.tsx", + "endpoint": "/api/members", + "note": "poison: hook-page API attributed to the query-page table" + }, + { + "component": "Table", + "instanceAt": "HookPage.tsx", + "endpoint": "/api/billing", + "note": "poison: query-page API attributed to the hook-page table" + } + ], + "queries": [ + { "terms": ["Billing rows"], "status": "ok", "top": "QueryPage" }, + { "terms": ["Member rows"], "status": "ok", "top": "HookPage" } + ] + } +} 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..4f282ec --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/golden.json @@ -0,0 +1,76 @@ +{ + "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"] + }, + { + "component": "DataTable", + "instanceAt": "pages/InvoicesPage.tsx", + "endpoints": ["/api/invoices"] + }, + { + "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" } + ], + "blast": [ + { + "node": "DataTable", + "note": "Changing the shared DataTable definition surfaces both page instances (distance 1) and the pages that render them (distance 2).", + "expect": [ + { "node": "DataTable", "kind": "instance", "at": "pages/UsersPage.tsx", "distance": 1 }, + { "node": "DataTable", "kind": "instance", "at": "pages/InvoicesPage.tsx", "distance": 1 }, + { "node": "UsersPage", "kind": "component", "distance": 2 }, + { "node": "InvoicesPage", "kind": "component", "distance": 2 } + ] + }, + { + "node": "/api/users", + "note": "Changing the users API surfaces every consumer β€” the fetching page and the table instance it feeds β€” but must NOT reach the invoices page or its API.", + "expect": [ + { "node": "UsersPage", "kind": "component", "distance": 1 }, + { "node": "DataTable", "kind": "instance", "at": "pages/UsersPage.tsx", "distance": 1 } + ], + "forbidden": ["InvoicesPage", "/api/invoices"] + } + ] + } +} diff --git a/eval/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/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/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/fixtures/c6-store-decoupled/app/CartWidget.tsx b/eval/fixtures/c6-store-decoupled/app/CartWidget.tsx new file mode 100644 index 0000000..e4a0960 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/CartWidget.tsx @@ -0,0 +1,8 @@ +import { useCartStore } from "./store/cartStore"; + +/** Zustand reader β€” the fetch lives inside the store's own load action. */ +export function CartWidget() { + const items = useCartStore((s) => s.items); + + return {items.length} in cart; +} diff --git a/eval/fixtures/c6-store-decoupled/app/LoginPage.tsx b/eval/fixtures/c6-store-decoupled/app/LoginPage.tsx new file mode 100644 index 0000000..fd84991 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/LoginPage.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; + +import { fetchUsers } from "./store/usersSlice"; + +export function LoginPage() { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchUsers()); + }, [dispatch]); + + return ( +
+

Welcome back

+
+ ); +} diff --git a/eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx b/eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx new file mode 100644 index 0000000..ccf6299 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx @@ -0,0 +1,17 @@ +import { useSelector } from "react-redux"; + +/** No fetch of its own β€” the data arrived via the slice, populated at login. */ +export function UserDirectory() { + const users = useSelector((s: { users: { list: string[] } }) => s.users.list); + + return ( +
+

User directory

+
    + {users.map((u) => ( +
  • {u}
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/c6-store-decoupled/app/store/cartStore.ts b/eval/fixtures/c6-store-decoupled/app/store/cartStore.ts new file mode 100644 index 0000000..a2bdea2 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/store/cartStore.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; + +interface CartState { + items: string[]; + load: () => Promise; +} + +export const useCartStore = create((set) => ({ + items: [], + load: async () => { + const res = await fetch("/api/cart"); + set({ items: (await res.json()) as string[] }); + }, +})); diff --git a/eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts b/eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts new file mode 100644 index 0000000..2b2669f --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts @@ -0,0 +1,17 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; + +export const fetchUsers = createAsyncThunk("users/fetch", async () => { + const res = await fetch("/api/users"); + return res.json(); +}); + +export const usersSlice = createSlice({ + name: "users", + initialState: { list: [] as string[] }, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchUsers.fulfilled, (state, action) => { + state.list = action.payload as string[]; + }); + }, +}); diff --git a/eval/fixtures/c6-store-decoupled/golden.json b/eval/fixtures/c6-store-decoupled/golden.json new file mode 100644 index 0000000..240cd63 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/golden.json @@ -0,0 +1,32 @@ +{ + "failureMode": "C6", + "note": "Temporal decoupling via stores: UserDirectory renders useSelector(s => s.users.list) and contains NO fetch β€” the API was called at login via dispatch(fetchUsers()). Attribution must flow reader β†’ slice β†’ writer thunk β†’ /api/users. Zustand variant: CartWidget reads a store whose own load action fetches /api/cart.", + "expect": { + "components": [ + { "name": "LoginPage", "instances": 0 }, + { "name": "UserDirectory", "instances": 0 }, + { "name": "CartWidget", "instances": 0 } + ], + "attributions": [ + { "component": "UserDirectory", "endpoints": ["/api/users"] }, + { "component": "LoginPage", "endpoints": ["/api/users"] }, + { "component": "CartWidget", "endpoints": ["/api/cart"] } + ], + "forbidden": [ + { + "component": "UserDirectory", + "endpoint": "/api/cart", + "note": "poison: unrelated store's API attributed to the redux reader" + }, + { + "component": "CartWidget", + "endpoint": "/api/users", + "note": "poison: redux slice's API attributed to the zustand reader" + } + ], + "queries": [ + { "terms": ["User directory"], "status": "ok", "top": "UserDirectory" }, + { "terms": ["Welcome back"], "status": "ok", "top": "LoginPage" } + ] + } +} 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/fixtures/demo-app/golden.json b/eval/fixtures/demo-app/golden.json new file mode 100644 index 0000000..48c24c2 --- /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/:id"] + }, + { + "component": "UserCard", + "endpoints": ["/api/users/:id"] + } + ], + "queries": [ + { "terms": ["Team Members"], "status": "ok", "top": "UserList" }, + { "terms": ["Remove member"], "status": "ok", "top": "UserCard" } + ] + } +} diff --git a/eval/fixtures/e2-business-vocab/aliases.yaml b/eval/fixtures/e2-business-vocab/aliases.yaml new file mode 100644 index 0000000..89cee36 --- /dev/null +++ b/eval/fixtures/e2-business-vocab/aliases.yaml @@ -0,0 +1,5 @@ +# Business-vocabulary glossary, checked into the target repo (TRACKER 4.6, E2). +# A phrase users say maps to the component it actually means, even when that +# phrase appears nowhere in the rendered text. +"invoice widget": BillingSummaryCard +"amount owed": BillingSummaryCard diff --git a/eval/fixtures/e2-business-vocab/app/BillingSummaryCard.tsx b/eval/fixtures/e2-business-vocab/app/BillingSummaryCard.tsx new file mode 100644 index 0000000..a68ed10 --- /dev/null +++ b/eval/fixtures/e2-business-vocab/app/BillingSummaryCard.tsx @@ -0,0 +1,8 @@ +export function BillingSummaryCard() { + return ( +
+

Billing summary

+ Amount due +
+ ); +} diff --git a/eval/fixtures/e2-business-vocab/app/InvoiceList.tsx b/eval/fixtures/e2-business-vocab/app/InvoiceList.tsx new file mode 100644 index 0000000..b8a433f --- /dev/null +++ b/eval/fixtures/e2-business-vocab/app/InvoiceList.tsx @@ -0,0 +1,7 @@ +export function InvoiceList() { + return ( +
    +
  • Recent invoices
  • +
+ ); +} diff --git a/eval/fixtures/e2-business-vocab/golden.json b/eval/fixtures/e2-business-vocab/golden.json new file mode 100644 index 0000000..b359fdd --- /dev/null +++ b/eval/fixtures/e2-business-vocab/golden.json @@ -0,0 +1,15 @@ +{ + "failureMode": "E2", + "note": "Business vocabulary: users say 'invoice widget' but the component is BillingSummaryCard and that phrase appears nowhere in its text. Without the glossary the query declines; with aliases.yaml loaded it resolves to BillingSummaryCard. Plain-text queries still work.", + "expect": { + "components": [ + { "name": "BillingSummaryCard", "instances": 0 }, + { "name": "InvoiceList", "instances": 0 } + ], + "queries": [ + { "terms": ["invoice widget"], "status": "ok", "top": "BillingSummaryCard" }, + { "terms": ["Billing summary"], "status": "ok", "top": "BillingSummaryCard" }, + { "terms": ["Recent invoices"], "status": "ok", "top": "InvoiceList" } + ] + } +} diff --git a/eval/fixtures/e3-annotated-screenshot/app/CostPanel.tsx b/eval/fixtures/e3-annotated-screenshot/app/CostPanel.tsx new file mode 100644 index 0000000..9a1bbaf --- /dev/null +++ b/eval/fixtures/e3-annotated-screenshot/app/CostPanel.tsx @@ -0,0 +1,8 @@ +export function CostPanel() { + return ( +
+

Cost breakdown

+ Total +
+ ); +} diff --git a/eval/fixtures/e3-annotated-screenshot/app/RevenuePanel.tsx b/eval/fixtures/e3-annotated-screenshot/app/RevenuePanel.tsx new file mode 100644 index 0000000..93113bb --- /dev/null +++ b/eval/fixtures/e3-annotated-screenshot/app/RevenuePanel.tsx @@ -0,0 +1,8 @@ +export function RevenuePanel() { + return ( +
+

Revenue breakdown

+ Total +
+ ); +} diff --git a/eval/fixtures/e3-annotated-screenshot/extraction.json b/eval/fixtures/e3-annotated-screenshot/extraction.json new file mode 100644 index 0000000..1ae3e2f --- /dev/null +++ b/eval/fixtures/e3-annotated-screenshot/extraction.json @@ -0,0 +1,13 @@ +{ + "note": "Pre-extracted vision output (checked in β€” no live API). The user circled 'Revenue breakdown'. Without the annotation this is ambiguous (RevenuePanel and CostPanel both share 'Total' and each own one 'breakdown' term); the 3x annotation boost resolves it to RevenuePanel.", + "terms": ["Revenue breakdown", "Cost breakdown", "Total"], + "structure": {}, + "annotations": [ + { + "kind": "circle", + "bounds": { "x": 20, "y": 40, "w": 180, "h": 32 }, + "terms": ["Revenue breakdown"] + } + ], + "looksLikeApp": true +} diff --git a/eval/fixtures/e3-annotated-screenshot/golden.json b/eval/fixtures/e3-annotated-screenshot/golden.json new file mode 100644 index 0000000..2bd93ae --- /dev/null +++ b/eval/fixtures/e3-annotated-screenshot/golden.json @@ -0,0 +1,10 @@ +{ + "failureMode": "E3", + "note": "Annotation-region weighting is verified in a unit test (packages/parser-react/src/vision.test.ts) against the checked-in extraction.json β€” the user circled 'Revenue breakdown', and the 3x boost resolves an otherwise-ambiguous match to RevenuePanel. This golden just asserts the two panels parse.", + "expect": { + "components": [ + { "name": "RevenuePanel", "instances": 0 }, + { "name": "CostPanel", "instances": 0 } + ] + } +} diff --git a/eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx b/eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx new file mode 100644 index 0000000..f9aabb1 --- /dev/null +++ b/eval/fixtures/f3-test-coverage/app/components/Sidebar.tsx @@ -0,0 +1,8 @@ +export function Sidebar() { + return ( + + ); +} diff --git a/eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx b/eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx new file mode 100644 index 0000000..6191c8b --- /dev/null +++ b/eval/fixtures/f3-test-coverage/app/components/UserList.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { UserList } from "./UserList"; + +describe("UserList", () => { + it("renders the users", () => { + render(); + expect(screen.getByText("Ada Lovelace")).toBeTruthy(); + }); +}); diff --git a/eval/fixtures/f3-test-coverage/app/components/UserList.tsx b/eval/fixtures/f3-test-coverage/app/components/UserList.tsx new file mode 100644 index 0000000..df795d5 --- /dev/null +++ b/eval/fixtures/f3-test-coverage/app/components/UserList.tsx @@ -0,0 +1,8 @@ +export function UserList() { + return ( +
    +
  • Ada Lovelace
  • +
  • Alan Turing
  • +
+ ); +} diff --git a/eval/fixtures/f3-test-coverage/golden.json b/eval/fixtures/f3-test-coverage/golden.json new file mode 100644 index 0000000..730465d --- /dev/null +++ b/eval/fixtures/f3-test-coverage/golden.json @@ -0,0 +1,14 @@ +{ + "failureMode": "F3", + "note": "Test-coverage mapping. A co-located UserList.test.tsx renders , so UserList is covered-by that test; Sidebar has no test and must be reported untested. Test files never become component/instance nodes (both components have 0 instances β€” the test's does not count).", + "expect": { + "components": [ + { "name": "UserList", "instances": 0 }, + { "name": "Sidebar", "instances": 0 } + ], + "coverage": [ + { "component": "UserList", "tests": ["UserList.test.tsx"] }, + { "component": "Sidebar", "untested": true } + ] + } +} diff --git a/eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx b/eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx new file mode 100644 index 0000000..fffa28d --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/InvoicesPage.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +import type { Invoice } from "./types"; + +// Source 2 (annotation): the response type is the annotation on the variable +// the fetch result lands in. +export function InvoicesPage() { + const [invoices, setInvoices] = useState([]); + useEffect(() => { + async function load() { + const data: Invoice[] = await fetch("/api/invoices").then((r) => r.json()); + setInvoices(data); + } + void load(); + }, []); + return ( +
+

Invoices

+ {invoices.map((i) => ( +

{i.number}

+ ))} +
+ ); +} diff --git a/eval/fixtures/f4-typed-responses/app/OrdersPage.tsx b/eval/fixtures/f4-typed-responses/app/OrdersPage.tsx new file mode 100644 index 0000000..4a68323 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/OrdersPage.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +// Source 3 (OpenAPI): the code says nothing about the shape β€” the response type +// is recovered from the spec by matching GET /api/orders. +export function OrdersPage() { + const [orders, setOrders] = useState[]>([]); + useEffect(() => { + fetch("/api/orders") + .then((r) => r.json()) + .then(setOrders); + }, []); + return ( +
+

Orders

+ {orders.map((o, i) => ( +

{String(o.id)}

+ ))} +
+ ); +} diff --git a/eval/fixtures/f4-typed-responses/app/UsersPage.tsx b/eval/fixtures/f4-typed-responses/app/UsersPage.tsx new file mode 100644 index 0000000..7efaa9d --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/UsersPage.tsx @@ -0,0 +1,20 @@ +import axios from "axios"; +import { useEffect, useState } from "react"; + +import type { User } from "./types"; + +// Source 1 (generic): the response type is the call's type argument. +export function UsersPage() { + const [users, setUsers] = useState([]); + useEffect(() => { + axios.get("/api/users").then((res) => setUsers(res.data)); + }, []); + return ( +
+

Users

+ {users.map((u) => ( +

{u.name}

+ ))} +
+ ); +} diff --git a/eval/fixtures/f4-typed-responses/app/openapi.json b/eval/fixtures/f4-typed-responses/app/openapi.json new file mode 100644 index 0000000..0e686b9 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/openapi.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Fixture API", "version": "1.0.0" }, + "paths": { + "/api/orders": { + "get": { + "responses": { + "200": { + "description": "orders", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Order" } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "status": { "type": "string" }, + "total": { "type": "number" } + } + } + } + } +} diff --git a/eval/fixtures/f4-typed-responses/app/types.ts b/eval/fixtures/f4-typed-responses/app/types.ts new file mode 100644 index 0000000..4705040 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/app/types.ts @@ -0,0 +1,11 @@ +export interface User { + id: number; + name: string; + email: string; +} + +export interface Invoice { + id: number; + number: string; + total: number; +} diff --git a/eval/fixtures/f4-typed-responses/golden.json b/eval/fixtures/f4-typed-responses/golden.json new file mode 100644 index 0000000..95aaf60 --- /dev/null +++ b/eval/fixtures/f4-typed-responses/golden.json @@ -0,0 +1,27 @@ +{ + "failureMode": "F4", + "note": "Response-schema linking from all three sources: a generic type argument (axios.get), a variable-type annotation (const data: Invoice[] = …fetch…), and an OpenAPI spec matched by endpoint (GET /api/orders β†’ Order[]).", + "scan": { "openapi": "openapi.json" }, + "expect": { + "responses": [ + { + "endpoint": "/api/users", + "name": "User[]", + "from": "generic", + "fields": ["id", "name", "email"] + }, + { + "endpoint": "/api/invoices", + "name": "Invoice[]", + "from": "annotation", + "fields": ["id", "number", "total"] + }, + { + "endpoint": "/api/orders", + "name": "Order[]", + "from": "openapi", + "fields": ["id", "status", "total"] + } + ] + } +} diff --git a/eval/fixtures/field-patterns/app/__tests__/UsersPage.test.tsx b/eval/fixtures/field-patterns/app/__tests__/UsersPage.test.tsx new file mode 100644 index 0000000..425a14e --- /dev/null +++ b/eval/fixtures/field-patterns/app/__tests__/UsersPage.test.tsx @@ -0,0 +1,6 @@ +import { renderWithProviders } from "../test-utils"; +import UsersPage from "../pages/UsersPage"; + +it("renders the team directory", () => { + renderWithProviders(); +}); diff --git a/eval/fixtures/field-patterns/app/components/index.ts b/eval/fixtures/field-patterns/app/components/index.ts new file mode 100644 index 0000000..de51af5 --- /dev/null +++ b/eval/fixtures/field-patterns/app/components/index.ts @@ -0,0 +1,4 @@ +// Barrel-of-barrel with a rename β€” the multi-hop chain the field app used +// everywhere (index.ts β†’ ui/index.ts β†’ definition). +export { DataGrid as Grid } from "./ui"; +export { StatusBadge } from "./ui"; diff --git a/eval/fixtures/field-patterns/app/components/ui/DataGrid.tsx b/eval/fixtures/field-patterns/app/components/ui/DataGrid.tsx new file mode 100644 index 0000000..f7cd790 --- /dev/null +++ b/eval/fixtures/field-patterns/app/components/ui/DataGrid.tsx @@ -0,0 +1,24 @@ +import { StatusBadge } from "./StatusBadge"; + +export function DataGrid({ rows }: { rows: string[] }) { + return ( + + + + + + + + + {rows.map((row) => ( + + + + + ))} + +
NameStatus
{row} + +
+ ); +} diff --git a/eval/fixtures/field-patterns/app/components/ui/StatusBadge.tsx b/eval/fixtures/field-patterns/app/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..850790b --- /dev/null +++ b/eval/fixtures/field-patterns/app/components/ui/StatusBadge.tsx @@ -0,0 +1,3 @@ +export function StatusBadge({ label }: { label: string }) { + return {label}; +} diff --git a/eval/fixtures/field-patterns/app/components/ui/index.ts b/eval/fixtures/field-patterns/app/components/ui/index.ts new file mode 100644 index 0000000..98fec70 --- /dev/null +++ b/eval/fixtures/field-patterns/app/components/ui/index.ts @@ -0,0 +1,2 @@ +export { DataGrid } from "./DataGrid"; +export * from "./StatusBadge"; diff --git a/eval/fixtures/field-patterns/app/loadable.tsx b/eval/fixtures/field-patterns/app/loadable.tsx new file mode 100644 index 0000000..7574c6b --- /dev/null +++ b/eval/fixtures/field-patterns/app/loadable.tsx @@ -0,0 +1,12 @@ +import { Suspense, type ComponentType, type LazyExoticComponent } from "react"; + +/** The field app's code-splitting helper: every page element goes through it. */ +export function Loadable(Component: LazyExoticComponent) { + return function LoadableWrapper() { + return ( + Loading…}> + + + ); + }; +} diff --git a/eval/fixtures/field-patterns/app/main.tsx b/eval/fixtures/field-patterns/app/main.tsx new file mode 100644 index 0000000..3a19b38 --- /dev/null +++ b/eval/fixtures/field-patterns/app/main.tsx @@ -0,0 +1,6 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { routes } from "./routes"; + +// The config is an imported identifier, not an inline array literal. +export const router = createBrowserRouter(routes); diff --git a/eval/fixtures/field-patterns/app/pages/HomePage.tsx b/eval/fixtures/field-patterns/app/pages/HomePage.tsx new file mode 100644 index 0000000..7bf3131 --- /dev/null +++ b/eval/fixtures/field-patterns/app/pages/HomePage.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from "react-router-dom"; + +export function HomePage() { + const navigate = useNavigate(); + return ( +
+

Field Ops

+ +
+ ); +} + +export default HomePage; diff --git a/eval/fixtures/field-patterns/app/pages/InvoicesPage.tsx b/eval/fixtures/field-patterns/app/pages/InvoicesPage.tsx new file mode 100644 index 0000000..d877eb8 --- /dev/null +++ b/eval/fixtures/field-patterns/app/pages/InvoicesPage.tsx @@ -0,0 +1,15 @@ +import { Grid } from "../components"; + +import { useListInvoicesQuery } from "../store/api/invoicesApi"; + +export function InvoicesPage() { + const { data } = useListInvoicesQuery(); + return ( +
+

Invoice history

+ +
+ ); +} + +export default InvoicesPage; diff --git a/eval/fixtures/field-patterns/app/pages/PreviewPane.tsx b/eval/fixtures/field-patterns/app/pages/PreviewPane.tsx new file mode 100644 index 0000000..56306c7 --- /dev/null +++ b/eval/fixtures/field-patterns/app/pages/PreviewPane.tsx @@ -0,0 +1,16 @@ +import { lazy } from "react"; + +import { Loadable } from "../loadable"; + +// The variable name differs from the definition name on purpose: only +// unwrapping the Loadable(lazy(() => import())) chain can link this usage. +const Invoices = Loadable(lazy(() => import("./InvoicesPage"))); + +export function PreviewPane() { + return ( + + ); +} diff --git a/eval/fixtures/field-patterns/app/pages/UsersPage.tsx b/eval/fixtures/field-patterns/app/pages/UsersPage.tsx new file mode 100644 index 0000000..e8b2d48 --- /dev/null +++ b/eval/fixtures/field-patterns/app/pages/UsersPage.tsx @@ -0,0 +1,15 @@ +import { Grid } from "@ui"; + +import { useGetUsersQuery } from "@store/api/usersApi"; + +export function UsersPage() { + const { data } = useGetUsersQuery(); + return ( +
+

Team directory

+ +
+ ); +} + +export default UsersPage; diff --git a/eval/fixtures/field-patterns/app/routes.tsx b/eval/fixtures/field-patterns/app/routes.tsx new file mode 100644 index 0000000..6d6ca06 --- /dev/null +++ b/eval/fixtures/field-patterns/app/routes.tsx @@ -0,0 +1,25 @@ +import { lazy } from "react"; +import type { RouteObject } from "react-router-dom"; + +import { Loadable } from "./loadable"; +import { HomePage } from "./pages/HomePage"; + +// Loadable(lazy(() => import())) page elements β€” the exact field pattern that +// produced 0 route nodes in the v0.3.0 validation run. +const UsersPage = Loadable(lazy(() => import("./pages/UsersPage"))); +const InvoicesPage = Loadable(lazy(() => import("./pages/InvoicesPage"))); + +// Route arrays composed across variables and spread together, not written +// inline at the createBrowserRouter call site. +const billingRoutes: RouteObject[] = [{ path: "invoices", element: }]; + +export const routes: RouteObject[] = [ + { + path: "/", + children: [ + { index: true, element: }, + { path: "users", element: }, + ...billingRoutes, + ], + }, +]; diff --git a/eval/fixtures/field-patterns/app/store/api/baseApi.ts b/eval/fixtures/field-patterns/app/store/api/baseApi.ts new file mode 100644 index 0000000..242baee --- /dev/null +++ b/eval/fixtures/field-patterns/app/store/api/baseApi.ts @@ -0,0 +1,7 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +export const baseApi = createApi({ + reducerPath: "api", + baseQuery: fetchBaseQuery({ baseUrl: "/api" }), + endpoints: () => ({}), +}); diff --git a/eval/fixtures/field-patterns/app/store/api/invoicesApi.ts b/eval/fixtures/field-patterns/app/store/api/invoicesApi.ts new file mode 100644 index 0000000..322f3fc --- /dev/null +++ b/eval/fixtures/field-patterns/app/store/api/invoicesApi.ts @@ -0,0 +1,14 @@ +import { baseApi } from "./baseApi"; + +export const invoicesApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + listInvoices: builder.query({ + query: () => ({ url: "/invoices" }), + }), + payInvoice: builder.mutation({ + query: (id) => ({ url: `/invoices/${id}/pay`, method: "POST" }), + }), + }), +}); + +export const { useListInvoicesQuery, usePayInvoiceMutation } = invoicesApi; diff --git a/eval/fixtures/field-patterns/app/store/api/usersApi.ts b/eval/fixtures/field-patterns/app/store/api/usersApi.ts new file mode 100644 index 0000000..797edcb --- /dev/null +++ b/eval/fixtures/field-patterns/app/store/api/usersApi.ts @@ -0,0 +1,11 @@ +import { baseApi } from "./baseApi"; + +export const usersApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getUsers: builder.query({ + query: () => "/users", + }), + }), +}); + +export const { useGetUsersQuery } = usersApi; diff --git a/eval/fixtures/field-patterns/app/test-utils.tsx b/eval/fixtures/field-patterns/app/test-utils.tsx new file mode 100644 index 0000000..9a10b28 --- /dev/null +++ b/eval/fixtures/field-patterns/app/test-utils.tsx @@ -0,0 +1,7 @@ +import { render } from "@testing-library/react"; +import type { ReactElement } from "react"; + +/** The app's custom render: every test goes through this providers wrapper. */ +export function renderWithProviders(ui: ReactElement) { + return render(
{ui}
); +} diff --git a/eval/fixtures/field-patterns/app/tsconfig.json b/eval/fixtures/field-patterns/app/tsconfig.json new file mode 100644 index 0000000..7949558 --- /dev/null +++ b/eval/fixtures/field-patterns/app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@ui": ["components/index.ts"], + "@store/*": ["store/*"] + } + } +} diff --git a/eval/fixtures/field-patterns/golden.json b/eval/fixtures/field-patterns/golden.json new file mode 100644 index 0000000..d9139e9 --- /dev/null +++ b/eval/fixtures/field-patterns/golden.json @@ -0,0 +1,43 @@ +{ + "failureMode": "D2", + "note": "Field-pattern regression fixture (6F.2): mirrors the shapes the 2026-07-15 ih-frontend validation run used where every gate scored 1.000 but the field run failed β€” tsconfig-path-alias barrel imports (@ui), Loadable(lazy(() => import())) page elements, route arrays composed in a separate file and passed to createBrowserRouter as an identifier, an RTK Query store (createApi/injectEndpoints/builder.query|mutation), and tests that render through a custom providers wrapper. Checks assert TARGET behavior; while a capability was missing its checks carried expectedFail naming the enabling step, and the unexpected-pass gate forced the marks off as each step landed (6F.3 aliases/lazy, 6F.4 RTK Query, 6F.5 object-config routes β€” all landed, no marks remain). Verified working already in this shape: relative multi-hop barrel with rename (InvoicesPage's Grid), and covered-by detection through the custom render wrapper.", + "expect": { + "components": [ + { "name": "HomePage", "instances": 0 }, + { "name": "UsersPage", "instances": 0 }, + { "name": "InvoicesPage", "instances": 1 }, + { "name": "PreviewPane", "instances": 0 }, + { "name": "DataGrid", "instances": 2 }, + { "name": "StatusBadge", "instances": 1 } + ], + "queries": [ + { "terms": ["Team directory"], "status": "ok", "top": "UsersPage" }, + { "terms": ["Invoice history"], "status": "ok", "top": "InvoicesPage" } + ], + "routes": [ + { "path": "/", "component": "HomePage", "layout": null, "guards": [] }, + { "path": "/users", "component": "UsersPage", "layout": null, "guards": [] }, + { "path": "/invoices", "component": "InvoicesPage", "layout": null, "guards": [] } + ], + "effects": [ + { "component": "HomePage", "event": "onClick", "effect": "navigates-to", "to": "/users" } + ], + "journeys": [ + { "start": "/", "expect": [{ "pages": ["/", "/users"], "end": "terminal" }] } + ], + "attributions": [ + { "component": "UsersPage", "endpoints": ["/api/users"] }, + { "component": "InvoicesPage", "endpoints": ["/api/invoices"] } + ], + "blast": [ + { + "node": "DataGrid", + "expect": [{ "node": "UsersPage" }, { "node": "InvoicesPage" }] + } + ], + "coverage": [ + { "component": "UsersPage", "tests": ["UsersPage.test"] }, + { "component": "StatusBadge", "untested": true } + ] + } +} 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/history.jsonl b/eval/history.jsonl new file mode 100644 index 0000000..b4106a2 --- /dev/null +++ b/eval/history.jsonl @@ -0,0 +1,12 @@ +{"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} +{"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} +{"generatedAt":"2026-07-13T11:10:56.348Z","commitSha":"b628c816231c4896f030ebc68a6621d0963d183c","pass":99,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1} +{"generatedAt":"2026-07-13T11:16:56.457Z","commitSha":"ce7a3bcbf95481114cd60ef62f8e840874ef7453","pass":108,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1} +{"generatedAt":"2026-07-13T11:25:04.854Z","commitSha":"cec11f7aab21ef6ec2d6dd0bb247e2cd29981ed9","pass":119,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1} +{"generatedAt":"2026-07-14T16:11:46.552Z","commitSha":"4c01687263ff4ed2d656970a2532754636714604","pass":129,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1} +{"generatedAt":"2026-07-14T16:19:16.873Z","commitSha":"acbf574f777e001b553ece6ea206ddbe1f1a3892","pass":137,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1} diff --git a/eval/package.json b/eval/package.json new file mode 100644 index 0000000..d51f585 --- /dev/null +++ b/eval/package.json @@ -0,0 +1,22 @@ +{ + "name": "@coderadar/eval", + "version": "0.4.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/agent-sdk": "workspace:*", + "@coderadar/core": "workspace:*", + "@coderadar/parser-react": "workspace:*", + "yaml": "^2.9.0" + }, + "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..2e6c7db --- /dev/null +++ b/eval/src/checks.ts @@ -0,0 +1,462 @@ +/** Run one fixture's golden checks against its scanned graph. */ + +import { + blastRadius, + journeys, + type LineageGraph, + type LineageNode, + matchComponents, + traceLineage, +} from "@coderadar/core"; + +import type { CheckResult, FixtureResult, Golden, QueryOutcome } from "./golden.js"; + +export function runChecks( + fixture: string, + golden: Golden, + graph: LineageGraph, + aliases?: Record, +): FixtureResult { + const checks: CheckResult[] = []; + const attribution = { truePositives: 0, falsePositives: 0, falseNegatives: 0 }; + const queryOutcomes: QueryOutcome[] = []; + + 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, + ); + // 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" && definition !== undefined && n.definitionId === definition.id, + ); + 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); + // xfail-marked attributions stay out of the precision/recall tallies: a + // known-missing capability must not depress the global lineage metrics + // while marked. The unexpected-pass gate forces the marker off the moment + // the capability lands, which restores tally counting. + const tally = expected.expectedFail === undefined; + if (found === null) { + if (tally) 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)); + if (tally) { + 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 expected of golden.expect.routes ?? []) { + const id = `route:${expected.path}`; + const route = graph.nodes.find((n) => n.kind === "route" && n.path === expected.path); + if (route === undefined || route.kind !== "route") { + finalize("routes", id, false, expected.expectedFail, "route not found in graph"); + continue; + } + const pageEdge = graph.edges.find((e) => e.kind === "routes-to" && e.from === route.id); + const page = pageEdge !== undefined ? graph.nodes.find((n) => n.id === pageEdge.to) : undefined; + let detail: string | undefined; + if (page?.name !== expected.component) { + detail = `expected page ${expected.component}, got ${page?.name ?? "none"}`; + } else if (expected.layout !== undefined && route.layout !== expected.layout) { + detail = `expected layout ${expected.layout ?? "null"}, got ${route.layout ?? "null"}`; + } else if ( + expected.guards !== undefined && + JSON.stringify(route.guards) !== JSON.stringify(expected.guards) + ) { + detail = `expected guards [${expected.guards.join(", ")}], got [${route.guards.join(", ")}]`; + } + finalize("routes", id, detail === undefined, expected.expectedFail, detail); + } + + for (const forbiddenPath of golden.expect.forbiddenRoutes ?? []) { + const present = graph.nodes.some((n) => n.kind === "route" && n.path === forbiddenPath); + // Like forbidden attributions, forbidden routes never xfail. + checks.push({ + id: `route!${forbiddenPath}`, + kind: "routes", + status: present ? "fail" : "pass", + detail: present ? "POISON: forbidden route present in graph" : undefined, + }); + } + + 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 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 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 expected of golden.expect.externals ?? []) { + const id = `external:${expected.kind}:${expected.component ?? expected.route}~${expected.host}`; + const node = (nid: string) => graph.nodes.find((n) => n.id === nid); + let matched = false; + if (expected.kind === "exits") { + const owner = graph.nodes.find((n) => n.kind === "component" && n.name === expected.component); + matched = graph.edges.some((e) => { + if (e.kind !== "exits-app") return false; + const target = node(e.to); + if (target?.kind !== "external" || target.host !== expected.host || owner === undefined) { + return false; + } + if (e.from === owner.id) return true; // direct + // otherwise an event handled by the owner component + return ( + node(e.from)?.kind === "event" && + graph.edges.some((h) => h.kind === "handles" && h.from === owner.id && h.to === e.from) + ); + }); + } else { + matched = graph.edges.some((e) => { + if (e.kind !== "enters-at") return false; + const route = node(e.to); + const from = node(e.from); + return ( + route?.kind === "route" && + route.path === expected.route && + from?.kind === "external" && + from.host === expected.host + ); + }); + } + finalize( + "externals", + id, + matched, + expected.expectedFail, + matched ? undefined : `no ${expected.kind}-app edge for ${expected.host}`, + ); + } + + const impactName = (n: LineageNode): string => + n.kind === "instance" + ? (graph.nodes.find((d) => d.id === n.definitionId)?.name ?? "") + : n.kind === "data-source" + ? n.endpoint + : n.kind === "route" + ? n.path + : n.kind === "event" + ? (n.handler ?? n.event) + : n.name; + + for (const spec of golden.expect.blast ?? []) { + const result = blastRadius(graph, spec.node); + const impacts = result.candidates[0]?.value ?? []; + for (const want of spec.expect) { + const id = `blast:${spec.node}=>${want.node}${want.at ? `@${want.at}` : ""}${want.distance !== undefined ? `[${want.distance}]` : ""}`; + const found = impacts.find( + (im) => + impactName(im.node) === want.node && + (want.kind === undefined || im.node.kind === want.kind) && + (want.at === undefined || im.node.loc.file.includes(want.at)) && + (want.distance === undefined || im.distance === want.distance), + ); + finalize( + "blast", + id, + result.status === "ok" && found !== undefined, + spec.expectedFail, + result.status !== "ok" + ? `blastRadius(${spec.node}) returned ${result.status}` + : found === undefined + ? `no impact ${want.node}${want.at ? ` at ${want.at}` : ""}${want.distance !== undefined ? ` at distance ${want.distance}` : ""}` + : undefined, + ); + } + // Over-reach guard: forbidden nodes never gate as xfail β€” a leak is always a failure. + for (const forbidden of spec.forbidden ?? []) { + const leaked = impacts.some((im) => impactName(im.node) === forbidden); + finalize( + "blast", + `blast:${spec.node}!=>${forbidden}`, + !leaked, + undefined, + leaked ? `over-reach: ${forbidden} appeared in the blast radius of ${spec.node}` : undefined, + ); + } + } + + for (const spec of golden.expect.coverage ?? []) { + const owner = graph.nodes.find((n) => n.kind === "component" && n.name === spec.component); + const testFiles = + owner === undefined + ? [] + : graph.edges + .filter((e) => e.kind === "covered-by" && e.from === owner.id) + .map((e) => graph.nodes.find((n) => n.id === e.to)?.loc.file) + .filter((f): f is string => f !== undefined); + if (spec.untested === true) { + finalize( + "coverage", + `coverage:${spec.component}!covered`, + owner !== undefined && testFiles.length === 0, + spec.expectedFail, + owner === undefined + ? "component not found" + : testFiles.length > 0 + ? `expected untested, but covered by [${testFiles.join(", ")}]` + : undefined, + ); + } + for (const want of spec.tests ?? []) { + const found = testFiles.some((f) => f.includes(want)); + finalize( + "coverage", + `coverage:${spec.component}<=${want}`, + owner !== undefined && found, + spec.expectedFail, + owner === undefined + ? "component not found" + : found + ? undefined + : `no test file matching "${want}" covers ${spec.component} (have [${testFiles.join(", ")}])`, + ); + } + } + + for (const spec of golden.expect.responses ?? []) { + const id = `response:${spec.endpoint}=>${spec.name}`; + const source = graph.nodes.find( + (n) => + n.kind === "data-source" && + n.endpoint === spec.endpoint && + (spec.method === undefined || n.method === spec.method), + ); + const rt = source?.kind === "data-source" ? source.responseType : undefined; + let passed = rt !== undefined && rt.name === spec.name; + let detail: string | undefined; + if (source === undefined) detail = `no data source for ${spec.endpoint}`; + else if (rt === undefined) detail = `no response type on ${spec.endpoint}`; + else if (rt.name !== spec.name) detail = `expected response ${spec.name}, got ${rt.name}`; + if (passed && rt !== undefined && spec.from !== undefined && rt.source !== spec.from) { + passed = false; + detail = `expected source ${spec.from}, got ${rt.source}`; + } + if (passed && rt !== undefined && spec.fields !== undefined) { + const have = new Set(rt.fields.map((f) => f.name)); + const missing = spec.fields.filter((f) => !have.has(f)); + if (missing.length > 0) { + passed = false; + detail = `missing fields [${missing.join(", ")}] (have [${[...have].join(", ")}])`; + } + } + finalize("responses", id, passed, spec.expectedFail, detail); + } + + for (const query of golden.expect.queries ?? []) { + const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`; + const result = matchComponents(graph, { + terms: query.terms, + structure: query.structure, + ...(aliases !== undefined ? { aliases } : {}), + }); + 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 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"}`; + } else if (query.context !== undefined) { + const ctx = (result.candidates[0]?.value.context ?? []).map((c) => c.name); + const missing = query.context.filter((c) => !ctx.includes(c)); + passed = missing.length === 0; + if (!passed) detail = `expected context [${query.context.join(", ")}], got [${ctx.join(", ")}]`; + } + if (passed && query.confidence !== undefined) { + const level = result.candidates[0]?.confidence.level; + passed = level === query.confidence; + if (!passed) detail = `expected confidence ${query.confidence}, got ${level ?? "none"}`; + } + } + finalize("queries", id, passed, query.expectedFail, detail); + + if (query.expectedFail === undefined) { + const top = result.candidates[0]; + queryOutcomes.push({ + terms: query.terms, + expectedStatus: query.status, + gotStatus: result.status, + ...(top !== undefined + ? { + top: top.value.component.name, + confidence: top.confidence.level, + score: top.confidence.score, + } + : {}), + correct: passed, + }); + } + } + + return { fixture, failureMode: golden.failureMode, checks, attribution, queries: queryOutcomes }; +} + +/** 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..80b14f1 --- /dev/null +++ b/eval/src/golden.ts @@ -0,0 +1,283 @@ +/** 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[]; + /** 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; + /** Ancestor names the top match must list as `context` (step 4.3). */ + context?: string[]; + /** When set, the top candidate's confidence level must equal this (A3/A12 honesty). */ + confidence?: "high" | "medium" | "low"; + /** + * 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; +} + +export interface GoldenRoute { + /** Route pattern in :param form, e.g. "/users/:id". */ + path: string; + /** Component name the route's routes-to edge must land on. */ + component: string; + /** When set, the RouteNode's layout must equal this exactly (null = bare). */ + layout?: string | null; + /** When set, the RouteNode's guards must equal this exactly. */ + guards?: string[]; + 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 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 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; +} + +/** A response-schema assertion (step 5.5, failure mode F4): a data source's response type. */ +export interface GoldenResponse { + /** Endpoint the data source resolves to. */ + endpoint: string; + /** HTTP method, to disambiguate same-endpoint sources. */ + method?: string; + /** Expected response-type name (e.g. "User[]"). */ + name: string; + /** Where the type must have come from. */ + from?: "generic" | "annotation" | "openapi"; + /** Field names that must appear in the response type. */ + fields?: string[]; + expectedFail?: string; +} + +/** A test-coverage assertion (step 5.4, failure mode F3): covered-by edges. */ +export interface GoldenCoverage { + component: string; + /** Test-file path substrings that must cover this component (via covered-by edges). */ + tests?: string[]; + /** When true, the component must carry NO covered-by edge. */ + untested?: boolean; + expectedFail?: string; +} + +/** A blast-radius assertion (step 5.3, failure mode F2): reverse-dependency reach. */ +export interface GoldenBlast { + /** Node to compute the blast radius from: component name, API endpoint, state name, or route path. */ + node: string; + /** Impacted nodes that must appear. */ + expect: Array<{ + /** Component/definition name, endpoint, state name, or route path of the impacted node. */ + node: string; + kind?: "component" | "instance" | "data-source" | "state" | "route" | "hook" | "event"; + /** Substring the impacted node's file path must contain (disambiguates instances). */ + at?: string; + /** Exact reverse-dependency distance, when asserted (1 = direct dependent). */ + distance?: number; + }>; + /** Definition/endpoint names that must NOT appear in the blast radius (over-reach guard). */ + forbidden?: string[]; + expectedFail?: string; +} + +/** A cross-app hop (step B9): an exits-app or enters-at edge. */ +export interface GoldenExternal { + kind: "exits" | "enters"; + /** exits: the component whose link/handler leaves the app. */ + component?: string; + /** enters: the inbound entry route path. */ + route?: string; + /** External host/scheme the edge connects to (accounts.google.com, mailto, inbound). */ + host: string; + expectedFail?: string; +} + +export interface Golden { + failureMode: string; + 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 }; + /** OpenAPI spec path (relative to the app dir) for response-schema linking (5.5). */ + openapi?: string; + }; + expect: { + components?: GoldenComponent[]; + attributions?: GoldenAttribution[]; + forbidden?: GoldenForbidden[]; + queries?: GoldenQuery[]; + 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[]; + /** 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[]; + /** Cross-app hops (B9): exits-app / enters-at edges. */ + externals?: GoldenExternal[]; + /** Blast-radius reverse-dependency reach (step 5.3, F2). */ + blast?: GoldenBlast[]; + /** Test coverage via covered-by edges (step 5.4, F3). */ + coverage?: GoldenCoverage[]; + /** Response types on data sources (step 5.5, F4). */ + responses?: GoldenResponse[]; + }; +} + +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" + | "conditions" + | "externals" + | "blast" + | "coverage" + | "responses"; + status: CheckStatus; + detail?: string; +} + +/** Per-query outcome, for confidence calibration and honesty metrics (step 4.5). */ +export interface QueryOutcome { + terms: string[]; + expectedStatus: "ok" | "ambiguous" | "declined"; + gotStatus: "ok" | "ambiguous" | "declined"; + /** Top candidate name + its confidence (ok results). */ + top?: string; + confidence?: "high" | "medium" | "low"; + score?: number; + /** ok: top === golden.top; otherwise gotStatus === expectedStatus. */ + correct: boolean; +} + +export interface FixtureResult { + fixture: string; + failureMode: string; + checks: CheckResult[]; + /** Attribution tallies for lineage precision/recall. */ + attribution: { truePositives: number; falsePositives: number; falseNegatives: number }; + /** Query outcomes (populated for fixtures with `queries`). */ + queries: QueryOutcome[]; +} + +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; + /** Fraction of high-confidence `ok` answers that are correct (step 4.5). */ + highConfidenceCorrect: number | null; + /** Fraction of genuinely-ambiguous queries the matcher returned ambiguous. */ + ambiguityHonesty: number | null; + /** Fraction of `ok` answers that are confidently wrong (the poison rate). */ + poisonRate: number | null; + /** Ticket entry-point classification accuracy (step 5.1). */ + entryPointAccuracy: number | null; + /** Fraction of out-of-domain tickets correctly declined (step 5.1). */ + oodRejection: number | null; + }; +} + +export interface Thresholds { + maxFail: number; + maxUnexpectedPass: number; + /** Optional metric floors; null-valued metrics are not gated. */ + minLineagePrecision?: number; + minLineageRecall?: number; + minMatchAccuracy?: number; + minHighConfidenceCorrect?: number; + minAmbiguityHonesty?: number; + maxPoisonRate?: number; + minEntryPointAccuracy?: number; + minOodRejection?: number; +} diff --git a/eval/src/run.ts b/eval/src/run.ts new file mode 100644 index 0000000..748043d --- /dev/null +++ b/eval/src/run.ts @@ -0,0 +1,288 @@ +/** + * 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 { classifyTicket, type EntryPoint } from "@coderadar/agent-sdk"; +import { CONFIDENCE_THRESHOLDS } from "@coderadar/core"; +import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; +import { parse as parseYaml } from "yaml"; + +interface TicketCase { + id: string; + text: string; + screenshots?: number; + links?: string[]; + expect: EntryPoint; +} + +/** Classify the hand-written ticket suite β†’ entry-point accuracy + OOD rejection (step 5.1). */ +function runTickets(): { entryPointAccuracy: number | null; oodRejection: number | null } { + const ticketsPath = path.join(evalDir, "tickets", "tickets.json"); + if (!fs.existsSync(ticketsPath)) return { entryPointAccuracy: null, oodRejection: null }; + const tickets = JSON.parse(fs.readFileSync(ticketsPath, "utf-8")) as TicketCase[]; + let correct = 0; + let ood = 0; + let oodRejected = 0; + const misses: string[] = []; + for (const ticket of tickets) { + const got = classifyTicket(ticket).entryPoint; + if (got === ticket.expect) correct += 1; + else misses.push(`${ticket.id}: expected ${ticket.expect}, got ${got}`); + if (ticket.expect === "out-of-domain") { + ood += 1; + if (got === "out-of-domain") oodRejected += 1; + } + } + if (misses.length > 0) console.log(`\n[tickets] misclassified: ${misses.join(" Β· ")}`); + return { + entryPointAccuracy: tickets.length > 0 ? round(correct / tickets.length) : null, + oodRejection: ood > 0 ? round(oodRejected / ood) : null, + }; +} + +import { runChecks } from "./checks.js"; +import type { FixtureResult, Golden, Scorecard, Thresholds } from "./golden.js"; + +/** A fixture's business-vocabulary glossary (aliases.yaml), phrase β†’ component. */ +function loadAliases(fixtureDir: string): Record | undefined { + const aliasPath = path.join(fixtureDir, "aliases.yaml"); + if (!fs.existsSync(aliasPath)) return undefined; + return parseYaml(fs.readFileSync(aliasPath, "utf-8")) as Record; +} + +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, ...(golden.scan ?? {}) })); + results.push(runChecks(name, golden, graph, loadAliases(fixtureDir))); + } + + 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", + ); + } + + if (process.argv.includes("--calibrate")) calibrate(results); + + print(scorecard); + gate(scorecard); +} + +/** + * Measure the score β†’ confidence calibration on the eval set and record it in + * eval/calibration.json: the precision at the matcher's `high` cutoff, plus the + * lowest score threshold at which precision still holds β‰₯ 0.95 (step 4.5). + */ +function calibrate(results: FixtureResult[]): void { + const ok = results + .flatMap((r) => r.queries) + .filter((o) => o.gotStatus === "ok" && o.score !== undefined); + const precisionAbove = (t: number): number => { + const bucket = ok.filter((o) => (o.score ?? 0) >= t); + return bucket.length > 0 ? bucket.filter((o) => o.correct).length / bucket.length : 1; + }; + const scores = [...new Set(ok.map((o) => o.score ?? 0))].sort((a, b) => b - a); + let empiricalHighFloor = 1; + for (const t of scores) { + if (precisionAbove(t) >= 0.95) empiricalHighFloor = t; + else break; + } + const calibration = { + generatedAt: new Date().toISOString(), + thresholds: CONFIDENCE_THRESHOLDS, + measuredPrecisionAtHigh: round(precisionAbove(CONFIDENCE_THRESHOLDS.high)), + empiricalHighFloor: round(empiricalHighFloor), + sampleSize: ok.length, + note: "The matcher's confidenceFromScore uses these thresholds. measuredPrecisionAtHigh is the fraction of high-confidence eval answers that are correct; empiricalHighFloor is the lowest score at which precision still holds >= 0.95.", + }; + fs.writeFileSync( + path.join(evalDir, "calibration.json"), + `${JSON.stringify(calibration, null, 2)}\n`, + ); + console.log( + `\nWrote calibration.json β€” high=${CONFIDENCE_THRESHOLDS.high}, precision@high=${round( + precisionAbove(CONFIDENCE_THRESHOLDS.high), + )}, floor=${round(empiricalHighFloor)} (n=${ok.length})`, + ); +} + +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; + + // Confidence & honesty metrics (step 4.5) over every non-xfail query outcome. + const outcomes = results.flatMap((r) => r.queries); + const okAnswers = outcomes.filter((o) => o.gotStatus === "ok"); + const highOk = okAnswers.filter((o) => o.confidence === "high"); + const ambiguous = outcomes.filter((o) => o.expectedStatus === "ambiguous"); + + 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, + highConfidenceCorrect: + highOk.length > 0 ? round(highOk.filter((o) => o.correct).length / highOk.length) : null, + ambiguityHonesty: + ambiguous.length > 0 + ? round(ambiguous.filter((o) => o.gotStatus === "ambiguous").length / ambiguous.length) + : null, + poisonRate: + okAnswers.length > 0 + ? round(okAnswers.filter((o) => !o.correct).length / okAnswers.length) + : null, + ...runTickets(), + }, + }; +} + +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)}`, + ); + console.log( + `high-conf correct=${fmt(s.highConfidenceCorrect)} Β· ambiguity honesty=${fmt(s.ambiguityHonesty)} Β· poison rate=${fmt(s.poisonRate)}`, + ); + console.log( + `ticket entry-point accuracy=${fmt(s.entryPointAccuracy)} Β· OOD rejection=${fmt(s.oodRejection)}`, + ); +} + +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); + checkFloor( + violations, + "highConfidenceCorrect", + s.highConfidenceCorrect, + thresholds.minHighConfidenceCorrect, + ); + checkFloor(violations, "ambiguityHonesty", s.ambiguityHonesty, thresholds.minAmbiguityHonesty); + checkFloor(violations, "entryPointAccuracy", s.entryPointAccuracy, thresholds.minEntryPointAccuracy); + checkFloor(violations, "oodRejection", s.oodRejection, thresholds.minOodRejection); + if ( + thresholds.maxPoisonRate !== undefined && + s.poisonRate !== null && + s.poisonRate > thresholds.maxPoisonRate + ) { + violations.push(`poisonRate ${s.poisonRate} > max ${thresholds.maxPoisonRate}`); + } + + 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..2fba2f1 --- /dev/null +++ b/eval/thresholds.json @@ -0,0 +1,12 @@ +{ + "maxFail": 0, + "maxUnexpectedPass": 0, + "minMatchAccuracy": 1, + "minLineagePrecision": 0.95, + "minLineageRecall": 0.95, + "minHighConfidenceCorrect": 1, + "minAmbiguityHonesty": 0.9, + "maxPoisonRate": 0.05, + "minEntryPointAccuracy": 0.9, + "minOodRejection": 0.95 +} diff --git a/eval/tickets/tickets.json b/eval/tickets/tickets.json new file mode 100644 index 0000000..cb1a82d --- /dev/null +++ b/eval/tickets/tickets.json @@ -0,0 +1,23 @@ +[ + { "id": "v1", "text": "Screenshot of the users table β€” the columns look wrong.", "screenshots": 1, "expect": "visual" }, + { "id": "v2", "text": "See attached; this is the invoice detail page after saving.", "screenshots": 1, "expect": "visual" }, + { "id": "v3", "text": "This dashboard card is misaligned. Grab from prod.", "screenshots": 1, "expect": "visual" }, + { "id": "v4", "text": "Attached a shot of the settings screen β€” the toggle is off.", "screenshots": 1, "expect": "visual" }, + { "id": "v5", "text": "Here is what the checkout page renders for me.", "screenshots": 1, "expect": "visual" }, + + { "id": "t1", "text": "The 'All invoices' page shows stale totals after a refund.", "expect": "textual" }, + { "id": "t2", "text": "Fix the label on the Billing summary card β€” it says the wrong currency.", "expect": "textual" }, + { "id": "t3", "text": "The Team Members list is missing the avatar column.", "expect": "textual" }, + { "id": "t4", "text": "Rename 'Save draft' to 'Save' on the editor toolbar.", "expect": "textual" }, + + { "id": "b1", "text": "Clicking the Save button does nothing on the profile page.", "expect": "behavioral" }, + { "id": "b2", "text": "After I submit the signup form, nothing happens β€” no error, no redirect.", "expect": "behavioral" }, + { "id": "b3", "text": "The Refresh button on the users page isn't working.", "expect": "behavioral" }, + + { "id": "o1", "text": "The users API endpoint returns 503 under load; investigate the database connection pool.", "expect": "out-of-domain" }, + { "id": "o2", "text": "Kubernetes deployment is crash-looping after the last migration.", "expect": "out-of-domain" }, + { "id": "o3", "text": "Redis cache latency spiked and the cron worker queue is backed up.", "expect": "out-of-domain" }, + + { "id": "u1", "text": "Screen recording attached showing the whole flow.", "links": ["https://files.example.com/bug-repro.mp4"], "expect": "unsupported" }, + { "id": "u2", "text": "I made a video of the checkout flow β€” see the recording.", "expect": "unsupported" } +] 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..d436288 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coderadar", "private": true, - "version": "0.1.0", + "version": "0.4.0", "description": "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.", "license": "MIT", "packageManager": "pnpm@11.2.2", @@ -12,6 +12,8 @@ "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", + "schemas": "pnpm --filter @coderadar/core schema" } } diff --git a/packages/agent-sdk/package.json b/packages/agent-sdk/package.json new file mode 100644 index 0000000..4a3367c --- /dev/null +++ b/packages/agent-sdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "@coderadar/agent-sdk", + "version": "0.4.0", + "private": true, + "description": "resolveContext orchestrator β€” classifies a ticket and runs the matching engine. Deterministic, no LLM in the node. Bundled into the published ui-lineage package.", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "schema": "node scripts/gen-schema.mjs" + }, + "dependencies": { + "@coderadar/core": "workspace:*" + }, + "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/agent-sdk/scripts/gen-schema.mjs b/packages/agent-sdk/scripts/gen-schema.mjs new file mode 100644 index 0000000..4e60641 --- /dev/null +++ b/packages/agent-sdk/scripts/gen-schema.mjs @@ -0,0 +1,12 @@ +/** Regenerate schemas/context-bundle.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/agent-sdk/scripts/schema-config.mjs b/packages/agent-sdk/scripts/schema-config.mjs new file mode 100644 index 0000000..d256b8f --- /dev/null +++ b/packages/agent-sdk/scripts/schema-config.mjs @@ -0,0 +1,19 @@ +/** + * Shared config for ContextBundle JSON Schema generation β€” used by + * gen-schema.mjs (writes the committed file) and the drift-gate test. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const generatorConfig = { + path: path.join(packageDir, "src/bundle.ts"), + tsconfig: path.join(packageDir, "tsconfig.json"), + type: "ContextBundle", + topRef: true, + additionalProperties: false, +}; + +/** Committed schema location (repo root β€” dist/ is gitignored). */ +export const schemaOutPath = path.resolve(packageDir, "../../schemas/context-bundle.schema.json"); diff --git a/packages/agent-sdk/src/agent-sdk.test.ts b/packages/agent-sdk/src/agent-sdk.test.ts new file mode 100644 index 0000000..9b8ea3e --- /dev/null +++ b/packages/agent-sdk/src/agent-sdk.test.ts @@ -0,0 +1,97 @@ +import type { ComponentNode, LineageGraph } from "@coderadar/core"; +import { nodeId } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { classifyTicket } from "./classify.js"; +import { extractTerms, resolveContext } from "./resolve.js"; + +const loc = (f: string) => ({ file: f, 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: [], + structure: { + table: 0, + columns: 0, + form: 0, + input: 0, + button: 0, + link: 0, + image: 0, + heading: 0, + list: 0, + repeated: 0, + }, + }; +} +const graph: LineageGraph = { + version: 2, + root: "/app", + generatedAt: "2026-01-01T00:00:00.000Z", + generator: "test", + nodes: [component("InvoiceList", ["All invoices"]), component("UserList", ["Team members"])], + edges: [], +}; + +describe("classifyTicket (TRACKER 5.1)", () => { + it("routes a screenshot to the visual path", () => { + expect(classifyTicket({ text: "looks wrong", screenshots: 1 }).entryPoint).toBe("visual"); + }); + + it("declines backend/infra/perf tickets as out-of-domain (E6)", () => { + expect(classifyTicket({ text: "the Redis cache latency spiked" }).entryPoint).toBe( + "out-of-domain", + ); + expect(classifyTicket({ text: "kubernetes deployment crash-loops" }).entryPoint).toBe( + "out-of-domain", + ); + }); + + it("routes an interaction failure to the behavioral path (E5)", () => { + expect(classifyTicket({ text: "clicking Save does nothing" }).entryPoint).toBe("behavioral"); + }); + + it("declines a video attachment as unsupported (E4)", () => { + expect(classifyTicket({ text: "repro", links: ["https://x/clip.mp4"] }).entryPoint).toBe( + "unsupported", + ); + expect(classifyTicket({ text: "see the screen recording" }).entryPoint).toBe("unsupported"); + }); + + it("treats UI prose as textual", () => { + expect(classifyTicket({ text: "the 'All invoices' page shows stale totals" }).entryPoint).toBe( + "textual", + ); + }); +}); + +describe("resolveContext (TRACKER 5.1)", () => { + it("declines an out-of-domain ticket with a structured reason", () => { + const result = resolveContext(graph, { text: "database migration failed on deploy" }); + expect(result.entryPoint).toBe("out-of-domain"); + expect(result.decline?.reason).toBe("out-of-scope"); + expect(result.match).toBeUndefined(); + }); + + it("declines a video ticket as unsupported input", () => { + const result = resolveContext(graph, { text: "x", links: ["https://x/a.mov"] }); + expect(result.decline?.reason).toBe("unsupported-input"); + }); + + it("matches a textual ticket against the graph", () => { + const result = resolveContext(graph, { text: "the 'All invoices' page is broken" }); + expect(result.entryPoint).toBe("textual"); + expect(result.match?.candidates[0]?.value.component.name).toBe("InvoiceList"); + }); + + it("extracts quoted phrases as terms, else capitalized runs", () => { + expect(extractTerms('the "Save draft" button')).toEqual(["Save draft"]); + expect(extractTerms("the Team Members list")).toContain("Team Members"); + }); +}); diff --git a/packages/agent-sdk/src/bundle.schema.test.ts b/packages/agent-sdk/src/bundle.schema.test.ts new file mode 100644 index 0000000..b42185f --- /dev/null +++ b/packages/agent-sdk/src/bundle.schema.test.ts @@ -0,0 +1,14 @@ +import fs from "node:fs"; + +import { createGenerator } from "ts-json-schema-generator"; +import { describe, expect, it } from "vitest"; + +import { generatorConfig, schemaOutPath } from "../scripts/schema-config.mjs"; + +describe("ContextBundle JSON Schema drift gate", () => { + it("committed schema matches the TS types β€” run `pnpm --filter @coderadar/agent-sdk schema` after 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/agent-sdk/src/bundle.test.ts b/packages/agent-sdk/src/bundle.test.ts new file mode 100644 index 0000000..7d4ed67 --- /dev/null +++ b/packages/agent-sdk/src/bundle.test.ts @@ -0,0 +1,100 @@ +import type { ComponentNode, DataSourceNode, LineageEdge, LineageGraph } from "@coderadar/core"; +import { nodeId } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { buildBundle, estimateTokens } from "./bundle.js"; + +const loc = (f: string) => ({ file: f, line: 1, endLine: 1 }); + +/** A component "BigCard" that fetches from `count` endpoints β€” a large lineage. */ +function graphWithLineage(count: number): LineageGraph { + const component: ComponentNode = { + id: nodeId("component", "BigCard.tsx", "BigCard"), + kind: "component", + name: "BigCard", + loc: loc("BigCard.tsx"), + exportName: "BigCard", + props: [], + renderedText: [{ text: "Big card overview", source: "jsx" }], + rendersComponents: [], + structure: { + table: 0, + columns: 0, + form: 0, + input: 0, + button: 0, + link: 0, + image: 0, + heading: 0, + list: 0, + repeated: 0, + }, + }; + const sources: DataSourceNode[] = []; + const edges: LineageEdge[] = []; + for (let i = 0; i < count; i += 1) { + const endpoint = `/api/resource/${i}/details/expanded`; + const id = nodeId("data-source", "BigCard.tsx", `fetch:${endpoint}`); + sources.push({ + id, + kind: "data-source", + name: endpoint, + loc: loc("BigCard.tsx"), + sourceKind: "fetch", + method: "GET", + endpoint, + raw: endpoint, + resolved: "full", + }); + edges.push({ from: component.id, to: id, kind: "fetches-from" }); + } + return { + version: 2, + root: "/app", + generatedAt: "2026-01-01T00:00:00.000Z", + generator: "test", + nodes: [component, ...sources], + edges, + }; +} + +describe("buildBundle (TRACKER 5.2, F1)", () => { + const graph = graphWithLineage(40); + + it("populates match, lineage, and journeys for a matched ticket", () => { + const bundle = buildBundle(graph, { text: "the Big card overview is wrong" }, { budgetTokens: 8000 }); + expect(bundle.status).toBe("matched"); + expect(bundle.match[0]?.component).toBe("BigCard"); + expect(bundle.lineage[0]?.dataSources.length).toBeGreaterThan(0); + expect(bundle.journeys.length).toBeGreaterThan(0); + }); + + it("stays within budget at 2k / 4k / 8k tokens", () => { + for (const budget of [2000, 4000, 8000]) { + const bundle = buildBundle(graph, { text: "the Big card overview" }, { budgetTokens: budget }); + expect(estimateTokens(bundle)).toBeLessThanOrEqual(budget); + expect(bundle.budget.used).toBeLessThanOrEqual(budget); + } + }); + + it("trims lower-priority sections first, recording each in warnings", () => { + // A budget that fits the match but not the full lineage. + const bundle = buildBundle(graph, { text: "the Big card overview" }, { budgetTokens: 300 }); + expect(bundle.match.length).toBeGreaterThan(0); // match is never dropped + expect(estimateTokens(bundle)).toBeLessThanOrEqual(300); + // history/tests/journeys go before lineage. + const trimmed = bundle.warnings.filter((w) => w.includes("trimmed")); + const order = trimmed.map((w) => w.match(/trimmed \d+ (\w+)/)?.[1]); + const idx = (s: string) => order.indexOf(s); + if (idx("journeys") !== -1 && idx("lineage") !== -1) { + expect(idx("journeys")).toBeLessThan(idx("lineage")); + } + }); + + it("declines out-of-domain and unsupported tickets with a warning, no match", () => { + const ood = buildBundle(graph, { text: "kubernetes deployment crash-looping" }); + expect(ood.status).toBe("declined"); + expect(ood.match).toEqual([]); + expect(ood.warnings.some((w) => w.includes("out-of-scope"))).toBe(true); + }); +}); diff --git a/packages/agent-sdk/src/bundle.ts b/packages/agent-sdk/src/bundle.ts new file mode 100644 index 0000000..2b6c70d --- /dev/null +++ b/packages/agent-sdk/src/bundle.ts @@ -0,0 +1,287 @@ +/** + * The context bundle (TRACKER step 5.2, failure mode F1): the budgeted, + * priority-trimmed payload an agent consumes. Sections are filled by the + * matching/lineage/journey engines; blastRadius, tests, and history are wired + * in steps 5.3/5.4/5.6 and ship as empty arrays until then. + * + * Budgeter: when the estimated token size exceeds `budgetTokens`, sections are + * emptied in reverse priority (history β†’ tests β†’ journeys β†’ blastRadius β†’ + * lineage; match is never dropped, only reduced to the top candidate), and each + * trim is recorded in `warnings`. + */ +import { + blastRadius, + type DataSourceNode, + type ImpactNode, + type JourneyPath, + journeys, + type LineageGraph, + type LineageNode, + traceLineage, +} from "@coderadar/core"; + +import { gitHistory } from "./history.js"; +import { resolveContext } from "./resolve.js"; +import type { EntryPoint, Ticket } from "./types.js"; + +export interface BundleMatch { + component: string; + instances: string[]; + confidence: "high" | "medium" | "low"; + evidence: string[]; +} + +export interface BundleDataSource { + method: string | null; + endpoint: string; + /** Response shape (5.5, F4), when recoverable β€” name + one level of fields. */ + responseType?: { name: string; fields: { name: string; type: string }[]; source: string }; +} + +export interface BundleLineageEntry { + /** Component name, or `Name@file:line` for a specific instance. */ + target: string; + dataSources: BundleDataSource[]; + state: string[]; + events: string[]; +} + +/** Reverse-traversal impact (step 5.3) β€” empty until then. */ +export interface BundleImpact { + node: string; + relation: string; + distance: number; +} + +/** Test coverage (step 5.4) β€” empty until then. */ +export interface BundleTest { + file: string; +} + +/** Recent git history (step 5.6, F5) β€” a commit touching the matched files. */ +export interface BundleCommit { + sha: string; + subject: string; + /** PR number parsed from a merge/squash subject, when present. */ + pr?: number; +} + +export interface ContextBundle { + ticket: { text: string; entryPoint: EntryPoint }; + status: "matched" | "ambiguous" | "declined"; + match: BundleMatch[]; + lineage: BundleLineageEntry[]; + journeys: JourneyPath[]; + blastRadius: BundleImpact[]; + tests: BundleTest[]; + history: BundleCommit[]; + warnings: string[]; + budget: { tokens: number; used: number }; +} + +export interface BundleOptions { + /** Token budget the finished bundle must fit under. Default 4000. */ + budgetTokens?: number; + /** Journey expansion depth around the match. Default 2. */ + journeyDepth?: number; + /** Max recent commits to include in the history section. Default 5. */ + historyLimit?: number; +} + +/** Trim sections in this reverse-priority order until the bundle fits its budget. */ +const TRIM_ORDER = ["history", "tests", "journeys", "blastRadius", "lineage"] as const; + +/** A deterministic, tokenizer-free size estimate (β‰ˆ 4 chars per token). */ +export function estimateTokens(bundle: ContextBundle): number { + return Math.ceil(JSON.stringify({ ...bundle, budget: undefined }).length / 4); +} + +/** Resolve a ticket into a budgeted context bundle. */ +export function buildBundle( + graph: LineageGraph, + ticket: Ticket, + options: BundleOptions = {}, +): ContextBundle { + const budgetTokens = options.budgetTokens ?? 4000; + const depth = options.journeyDepth ?? 2; + const historyLimit = options.historyLimit ?? 5; + const ctx = resolveContext(graph, ticket); + + const bundle: ContextBundle = { + ticket: { text: ticket.text, entryPoint: ctx.entryPoint }, + status: "declined", + match: [], + lineage: [], + journeys: [], + blastRadius: [], + tests: [], + history: [], + warnings: [], + budget: { tokens: budgetTokens, used: 0 }, + }; + + if (ctx.decline !== undefined) { + bundle.warnings.push(`declined (${ctx.decline.reason}): ${ctx.decline.message}`); + return trimToBudget(bundle, budgetTokens); + } + const match = ctx.match; + if (match === undefined || match.status === "declined") { + bundle.warnings.push(`no component matched (${match?.declineReason ?? "no result"})`); + return trimToBudget(bundle, budgetTokens); + } + + bundle.status = match.status === "ambiguous" ? "ambiguous" : "matched"; + const limit = match.status === "ambiguous" ? 5 : 3; + for (const candidate of match.candidates.slice(0, limit)) { + bundle.match.push({ + component: candidate.value.component.name, + instances: candidate.value.instances.map((i) => `${i.loc.file}:${i.loc.line}`), + confidence: candidate.confidence.level, + evidence: candidate.evidence.map((e) => e.detail), + }); + } + if (match.status === "ambiguous" && match.disambiguation !== undefined) { + bundle.warnings.push(`ambiguous β€” ${match.disambiguation}`); + } + + const top = match.candidates[0]?.value; + if (top !== undefined) { + const definitionLineage = traceLineage(graph, top.component.id).candidates[0]?.value; + if (definitionLineage !== undefined) { + bundle.lineage.push({ + target: top.component.name, + dataSources: definitionLineage.dataSources.map(bundleDataSource), + state: definitionLineage.state.map((s) => s.name), + events: definitionLineage.events.map((e) => e.event), + }); + } + for (const instance of top.instances) { + const instLineage = traceLineage(graph, instance.id).candidates[0]?.value; + if (instLineage === undefined || instLineage.dataSources.length === 0) continue; + bundle.lineage.push({ + target: `${top.component.name}@${instance.loc.file}:${instance.loc.line}`, + dataSources: instLineage.dataSources.map(bundleDataSource), + state: [], + events: [], + }); + } + bundle.journeys = journeys(graph, top.component.name, { depth }).candidates[0]?.value ?? []; + + const impacts = blastRadius(graph, top.component.id).candidates[0]?.value ?? []; + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + bundle.blastRadius = impacts.map((impact) => ({ + node: impactLabel(impact, byId), + relation: impact.relation, + distance: impact.distance, + })); + + // Test coverage (5.4): tests over the matched component and its render subtree. + const subtree = componentSubtree(graph, top.component.id); + const testFiles = new Set(); + let topCovered = false; + for (const edge of graph.edges) { + if (edge.kind !== "covered-by" || !subtree.has(edge.from)) continue; + if (edge.from === top.component.id) topCovered = true; + const test = byId.get(edge.to); + if (test !== undefined) testFiles.add(test.loc.file); + } + bundle.tests = [...testFiles].sort().map((file) => ({ file })); + if (!topCovered) bundle.warnings.push(`untested β€” no test renders ${top.component.name}`); + + // Git history (5.6): recent commits touching the matched component's files. + const files = new Set([top.component.loc.file]); + for (const instance of top.instances) files.add(instance.loc.file); + for (const id of subtree) { + const node = byId.get(id); + if (node !== undefined) files.add(node.loc.file); + } + bundle.history = gitHistory(graph.root, [...files], historyLimit).map((commit) => ({ + sha: commit.sha, + subject: commit.subject, + ...(commit.pr !== undefined ? { pr: commit.pr } : {}), + })); + } + + if (graph.meta?.dirty === true) { + bundle.warnings.push("graph built from a dirty working tree β€” may not match committed code"); + } + const incomplete = graph.nodes.filter((n) => n.flags?.includes("incomplete")).length; + if (incomplete > 0) bundle.warnings.push(`${incomplete} node(s) could not be fully parsed`); + + return trimToBudget(bundle, budgetTokens); +} + +/** Project a data-source node into the bundle shape, carrying its response type when present. */ +function bundleDataSource(d: DataSourceNode): BundleDataSource { + return { + method: d.method, + endpoint: d.endpoint, + ...(d.responseType !== undefined ? { responseType: d.responseType } : {}), + }; +} + +/** Component ids in the render subtree of `rootId` (itself plus renders β†’ instance-of descendants). */ +function componentSubtree(graph: LineageGraph, rootId: string): Set { + const rendersFrom = new Map(); + const definitionOf = new Map(); + for (const edge of graph.edges) { + if (edge.kind === "renders") { + const list = rendersFrom.get(edge.from); + if (list) list.push(edge.to); + else rendersFrom.set(edge.from, [edge.to]); + } else if (edge.kind === "instance-of") { + definitionOf.set(edge.from, edge.to); + } + } + const seen = new Set([rootId]); + const stack = [rootId]; + while (stack.length > 0) { + const id = stack.pop() as string; + for (const instanceId of rendersFrom.get(id) ?? []) { + const defId = definitionOf.get(instanceId); + if (defId !== undefined && !seen.has(defId)) { + seen.add(defId); + stack.push(defId); + } + } + } + return seen; +} + +/** A compact, agent-readable name for one impacted node, with its source location. */ +function impactLabel(impact: ImpactNode, byId: Map): string { + const node = impact.node; + const where = `${node.loc.file}:${node.loc.line}`; + if (node.kind === "instance") { + const defName = byId.get(node.definitionId)?.name ?? "?"; + return `${defName}@${where}`; + } + const name = + node.kind === "data-source" + ? node.endpoint + : node.kind === "route" + ? node.path + : node.kind === "event" + ? (node.handler ?? node.event) + : node.name; + return `${node.kind} ${name} (${where})`; +} + +function trimToBudget(bundle: ContextBundle, budgetTokens: number): ContextBundle { + for (const section of TRIM_ORDER) { + if (estimateTokens(bundle) <= budgetTokens) break; + const list = bundle[section]; + if (Array.isArray(list) && list.length > 0) { + const n = list.length; + list.length = 0; + bundle.warnings.push(`trimmed ${n} ${section} entr${n === 1 ? "y" : "ies"} to fit the ${budgetTokens}-token budget`); + } + } + if (estimateTokens(bundle) > budgetTokens && bundle.match.length > 1) { + const dropped = bundle.match.length - 1; + bundle.match = bundle.match.slice(0, 1); + bundle.warnings.push(`trimmed ${dropped} lower-ranked match candidate(s) to fit the budget`); + } + bundle.budget.used = estimateTokens(bundle); + return bundle; +} diff --git a/packages/agent-sdk/src/classify.ts b/packages/agent-sdk/src/classify.ts new file mode 100644 index 0000000..30416de --- /dev/null +++ b/packages/agent-sdk/src/classify.ts @@ -0,0 +1,46 @@ +/** + * Rule-based ticket classification (TRACKER step 5.1). Deterministic, no LLM. + * Precedence: unsupported β†’ visual β†’ out-of-domain β†’ behavioral β†’ textual. + */ +import type { Classification, Ticket } from "./types.js"; + +/** Attachment URLs that are video/GIF, not still images (E4). */ +const VIDEO_LINK = /\.(mp4|mov|webm|avi|mkv|gif)(\?|#|$)/i; +const VIDEO_MENTION = /\b(screen[-\s]?recording|screencast|video (attached|recording|of))\b/i; + +/** Backend / infra / performance vocabulary β€” outside the UI codebase (E6). */ +const OUT_OF_DOMAIN = + /\b(database|migration|sql|postgres|mysql|mongo|redis|kafka|rabbitmq|kubernetes|k8s|docker|deploy(ment|ments|s|ed|ing)?|infra(structure)?|terraform|cron|job queue|background job|worker|latency|throughput|memory leak|cpu usage|rate[-\s]?limit(ing|ed)?|webhook|ci\/cd|pipeline|nginx|load balancer|env(ironment)? variable|api gateway|microservice|502|503|504|gateway timeout|connection pool|index(ing)? (the )?table)\b/i; + +/** Behavior/interaction phrasing β€” the entry point is an action, not a visual (E5). */ +const BEHAVIORAL = + /\b(nothing happens|does(n'?t| not) (work|respond|do anything|fire|trigger|navigate|submit|save|load|open|close)|no (response|reaction|effect)|not working|unresponsive|isn'?t working|won'?t (submit|save|open|close|load|work)|after (i |you |we )?(click|press|tap|submit|hit)|when (i |you |we )?(click|press|tap|submit|hit)|clicking .* (does|has) )/i; + +/** Classify a ticket into an entry point (E1/E5/E6/E4). */ +export function classifyTicket(ticket: Ticket): Classification { + const text = ticket.text ?? ""; + const links = ticket.links ?? []; + + if (links.some((l) => VIDEO_LINK.test(l)) || VIDEO_MENTION.test(text)) { + return { + entryPoint: "unsupported", + reason: "video/GIF attachment β€” no still frame to match; ask for a screenshot", + }; + } + if ((ticket.screenshots ?? 0) > 0) { + return { entryPoint: "visual", reason: "screenshot attached β€” match on its text and structure" }; + } + if (OUT_OF_DOMAIN.test(text)) { + return { + entryPoint: "out-of-domain", + reason: "backend/infra/perf vocabulary β€” not a UI ticket", + }; + } + if (BEHAVIORAL.test(text)) { + return { + entryPoint: "behavioral", + reason: "describes an interaction/failure, not a visual β€” match on event/journey vocabulary", + }; + } + return { entryPoint: "textual", reason: "UI terms in prose β€” match on rendered text" }; +} diff --git a/packages/agent-sdk/src/history.test.ts b/packages/agent-sdk/src/history.test.ts new file mode 100644 index 0000000..67cff8e --- /dev/null +++ b/packages/agent-sdk/src/history.test.ts @@ -0,0 +1,46 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { gitHistory, parsePrNumber } from "./history.js"; + +// This repo's own root β€” packages/agent-sdk/src β†’ ../../.. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +describe("gitHistory (TRACKER 5.6)", () => { + it("returns recent commits touching a tracked file", () => { + const commits = gitHistory(repoRoot, ["TRACKER.md"], 5); + expect(commits.length).toBeGreaterThan(0); + expect(commits.length).toBeLessThanOrEqual(5); + for (const commit of commits) { + expect(commit.sha).toMatch(/^[0-9a-f]{7,}$/); + expect(typeof commit.subject).toBe("string"); + } + }); + + it("parses PR numbers out of merge and squash subjects", () => { + expect(parsePrNumber("Merge pull request #38 from officialCodeWork/x")).toBe(38); + expect(parsePrNumber("feat(response): typed responses (#12)")).toBe(12); + expect(parsePrNumber("plain commit with no pr")).toBeUndefined(); + }); + + it("attaches a PR number when a commit subject carries one", () => { + // Merge commits carry "#NN"; whether they surface for a given file depends on + // the merge strategy, so assert the shape holds for any that do. + const commits = gitHistory(repoRoot, ["TRACKER.md"], 20); + for (const commit of commits) { + if (/#\d+/.test(commit.subject)) expect(typeof commit.pr).toBe("number"); + else expect(commit.pr).toBeUndefined(); + } + }); + + it("degrades to an empty list outside a git repo", () => { + expect(gitHistory("/nonexistent-path-xyz", ["whatever.ts"], 5)).toEqual([]); + }); + + it("returns empty for no files or a non-positive limit", () => { + expect(gitHistory(repoRoot, [], 5)).toEqual([]); + expect(gitHistory(repoRoot, ["TRACKER.md"], 0)).toEqual([]); + }); +}); diff --git a/packages/agent-sdk/src/history.ts b/packages/agent-sdk/src/history.ts new file mode 100644 index 0000000..c5289d5 --- /dev/null +++ b/packages/agent-sdk/src/history.ts @@ -0,0 +1,57 @@ +/** + * Git-history context (TRACKER step 5.6, failure mode F5). + * + * The last commits that touched the matched files, so an agent knows what + * recently changed around a ticket. Pure `git` subprocess, no network; any + * failure (not a repo, git missing, untracked files) yields an empty list + * rather than throwing β€” the bundle degrades gracefully. + */ + +import { execFileSync } from "node:child_process"; + +export interface CommitInfo { + /** Abbreviated commit hash. */ + sha: string; + subject: string; + /** PR number parsed from a merge/squash subject, when present. */ + pr?: number; +} + +const UNIT = "\x1f"; // field separator unlikely to appear in a subject + +/** Extract a PR number from a merge ("… pull request #12 …") or squash ("… (#12)") subject. */ +export function parsePrNumber(subject: string): number | undefined { + const match = /#(\d+)/.exec(subject); + return match !== null ? Number(match[1]) : undefined; +} + +/** The most recent `limit` commits touching any of `files` (relative to `root`). */ +export function gitHistory(root: string, files: string[], limit: number): CommitInfo[] { + if (files.length === 0 || limit <= 0) return []; + const seen = new Map(); + for (const file of files) { + let out: string; + try { + out = execFileSync( + "git", + ["-C", root, "log", "--follow", "-n", String(limit), `--format=%h${UNIT}%ct${UNIT}%s`, "--", file], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }, + ); + } catch { + continue; // not a repo, untracked path, or git unavailable + } + for (const line of out.split("\n")) { + if (line.trim() === "") continue; + const [sha, ct, subject] = line.split(UNIT); + if (sha === undefined || ct === undefined || subject === undefined) continue; + if (!seen.has(sha)) seen.set(sha, { subject, time: Number(ct) }); + } + } + return [...seen.entries()] + .sort((a, b) => b[1].time - a[1].time) + .slice(0, limit) + .map(([sha, { subject }]) => { + const pr = parsePrNumber(subject); + return pr !== undefined ? { sha, subject, pr } : { sha, subject }; + }); +} diff --git a/packages/agent-sdk/src/index.ts b/packages/agent-sdk/src/index.ts new file mode 100644 index 0000000..e371761 --- /dev/null +++ b/packages/agent-sdk/src/index.ts @@ -0,0 +1,14 @@ +export type { Classification, ContextResult, EntryPoint, Ticket } from "./types.js"; +export { classifyTicket } from "./classify.js"; +export { extractTerms, resolveContext } from "./resolve.js"; +export { + type BundleCommit, + type BundleImpact, + type BundleLineageEntry, + type BundleMatch, + type BundleOptions, + type BundleTest, + buildBundle, + type ContextBundle, + estimateTokens, +} from "./bundle.js"; diff --git a/packages/agent-sdk/src/resolve.ts b/packages/agent-sdk/src/resolve.ts new file mode 100644 index 0000000..cd866b0 --- /dev/null +++ b/packages/agent-sdk/src/resolve.ts @@ -0,0 +1,48 @@ +/** + * resolveContext (TRACKER step 5.1): classify a ticket, then run the matching + * engine with the signals available. Out-of-domain and unsupported tickets are + * declined with a structured reason rather than forced into a match. + */ +import { type LineageGraph, matchComponents } from "@coderadar/core"; + +import { classifyTicket } from "./classify.js"; +import type { ContextResult, Ticket } from "./types.js"; + +export function resolveContext(graph: LineageGraph, ticket: Ticket): ContextResult { + const classification = classifyTicket(ticket); + const { entryPoint } = classification; + + if (entryPoint === "out-of-domain") { + return { + entryPoint, + classification, + decline: { + reason: "out-of-scope", + message: + "This reads as a backend / infrastructure / performance ticket β€” outside the UI codebase CodeRadar maps. Route it to a human or a backend tool.", + }, + }; + } + if (entryPoint === "unsupported") { + return { + entryPoint, + classification, + decline: { + reason: "unsupported-input", + message: "Video/GIF attachments aren't supported β€” attach a still screenshot of the state.", + }, + }; + } + + return { entryPoint, classification, match: matchComponents(graph, { terms: extractTerms(ticket.text) }) }; +} + +/** Pull candidate UI terms from ticket prose: quoted phrases, else capitalized runs. */ +export function extractTerms(text: string): string[] { + const quoted = [...text.matchAll(/["'`]([^"'`]{2,})["'`]/g)].map((m) => m[1] ?? ""); + const cleaned = quoted.filter((t) => t.trim().length > 1); + if (cleaned.length > 0) return cleaned; + const caps = [...text.matchAll(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z0-9]+){0,3})\b/g)].map((m) => m[1] ?? ""); + const distinctive = caps.filter((t) => t.length > 2); + return distinctive.length > 0 ? distinctive : [text]; +} diff --git a/packages/agent-sdk/src/types.ts b/packages/agent-sdk/src/types.ts new file mode 100644 index 0000000..27d6cce --- /dev/null +++ b/packages/agent-sdk/src/types.ts @@ -0,0 +1,35 @@ +/** + * The agent-facing entry point (TRACKER step 5.1). A ticket comes in; a + * classified, matched context result comes out β€” or an honest decline. + */ +import type { ComponentMatch, QueryResult } from "@coderadar/core"; + +export interface Ticket { + /** The ticket prose. */ + text: string; + /** Number of screenshots attached (presence routes to the visual path). */ + screenshots?: number; + /** Attachment/reference URLs; a video/GIF here makes the input unsupported (E4). */ + links?: string[]; +} + +/** + * How CodeRadar should read the ticket (E1/E5/E6/E4). Classification is + * rule-based + keyword lexicons β€” no LLM call, so the node is deterministic (G8). + */ +export type EntryPoint = "visual" | "textual" | "behavioral" | "out-of-domain" | "unsupported"; + +export interface Classification { + entryPoint: EntryPoint; + /** Human/agent-readable reason for the routing decision. */ + reason: string; +} + +export interface ContextResult { + entryPoint: EntryPoint; + classification: Classification; + /** The component match β€” present for visual / textual / behavioral tickets. */ + match?: QueryResult; + /** A structured decline β€” present for out-of-domain / unsupported tickets. */ + decline?: { reason: "out-of-scope" | "unsupported-input"; message: string }; +} diff --git a/packages/agent-sdk/tsconfig.json b/packages/agent-sdk/tsconfig.json new file mode 100644 index 0000000..90d6d34 --- /dev/null +++ b/packages/agent-sdk/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..1a95d96 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,141 @@ +# ui-lineage + +**Map UI components to their data, journeys, and behavior** β€” trace any screenshot or ticket back to the code, APIs, state, events, and navigation behind it. Deterministic static analysis for React/TSX. No LLM in the core, no network calls. + +`ui-lineage` scans a React codebase into a **lineage graph** and answers three questions: + +1. **match** β€” text or structure seen on screen β†’ the component(s) that render it (rarity-weighted, fuzzy/OCR-tolerant, structure-aware, with business-vocab aliases and human corrections, and *calibrated* confidence). +2. **trace** β€” a component β†’ every API, state slice, and event that feeds it, *attributed per instance* (a shared `` reports `/api/users` on the Users page and `/api/invoices` on Invoices). +3. **journeys** β€” a page β†’ the user-action paths out of it (click β†’ navigate β†’ click…), lazily expanded and cycle-safe, with flag/role conditions. + +## Install + +```bash +npm install -g ui-lineage # CLI +npm install ui-lineage # library +``` + +Requires Node β‰₯ 20. + +## CLI + +```bash +ui-lineage scan ./src -o app.graph.json # scan a React app into a graph +ui-lineage scan ./src --openapi openapi.json # …and link data sources to response types +ui-lineage find "All invoices" -g app.graph.json # text β†’ component +ui-lineage find "invoice widget" -a aliases.yaml # resolve business vocabulary +ui-lineage trace InvoicesPage -g app.graph.json # component β†’ data/state/events +ui-lineage journeys /users -g app.graph.json # user-journey paths from a page +ui-lineage impact /api/users -g app.graph.json # blast radius: everything that depends on a node +ui-lineage resolve "cart total is wrong" # classify a ticket, then match it to components +ui-lineage bundle "cart total is wrong" -b 4000 # a budgeted context bundle (JSON) for an agent +ui-lineage correct BillingCard "amount owed" # record a correction for next time +ui-lineage visualize -g app.graph.json -o app.galaxy.html # interactive HTML graph explorer +``` + +`visualize` renders the whole graph as a single self-contained HTML file β€” the graph +JSON plus all CSS/JS inlined, zero network dependencies β€” that opens in any browser +(`open app.galaxy.html`, no server needed). Nodes are laid out as a canvas +force-directed "galaxy", color-coded by kind (component, instance, hook, data-source, +state, event, route, external, test); components are the stars, their data/state/events +cluster around them, routes act as gravitational centers. Toggle node and edge kinds, +search a node and fly to it, click a node for its details plus a highlighted +neighborhood (a visual blast radius), drag to pin, scroll to zoom, and pause the +physics. Built to stay responsive at real-codebase scale (thousands of nodes). + +`impact ` takes a component name, an API endpoint, a state name, or a route +path, and lists every node that depends on it (reverse traversal), each indented by +its distance β€” so a change can be reviewed for what it might break: + +``` + [instance-of] instance DataTable (pages/UsersPage.tsx:17) + [renders] component UsersPage (pages/UsersPage.tsx:5) +``` + +`journeys` reads left-to-right, with `↩ cycle` where a list ⇄ detail loop closes: + +``` + β–Έ /users β€’ onClick() β†’ /users/:id β–Έ /users/:id β€’ onClick() β†’ /users β–Έ /users ↩ cycle + β–Έ /users β€’ onClick() β‡’ fetch /api/users +``` + +## MCP server + +`ui-lineage` also ships an MCP (Model Context Protocol) server, so an agent can +query a pre-built graph over stdio. Build a graph, then point an MCP client at +the `ui-lineage-mcp` bin with the graph path in `CODERADAR_GRAPH`: + +```jsonc +// e.g. an MCP client config +{ + "mcpServers": { + "coderadar": { + "command": "ui-lineage-mcp", + "env": { "CODERADAR_GRAPH": "/abs/path/to/app.graph.json" } + } + } +} +``` + +Tools: `resolve_context(ticket)` (β†’ a budgeted context bundle), `find_component(terms)`, +`trace_lineage(id)`, `journeys(start, depth?)`, `blast_radius(node, depth?)`. Every +tool returns a QueryResult envelope β€” ranked candidates with confidence, or an +honest `ambiguous` / `declined`. There is no LLM in the server; it is a +deterministic context provider. + +## Library + +```ts +import { + scanReact, resolveHookEdges, + matchComponents, traceLineage, journeys, + recordCorrection, loadCorrections, +} from "ui-lineage"; + +const graph = resolveHookEdges(scanReact({ root: "./src" })); + +const match = matchComponents(graph, { terms: ["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` (with a disambiguation question built from the candidates' differences) / `declined` (machine-readable reason). + +### Screenshots (`ui-lineage/vision`) + +```ts +import { StubVisionAdapter, matchFromVision } from "ui-lineage/vision"; +``` + +Turn a screenshot into `{ terms, structure, annotations }` and match it β€” terms the user circled are weighted 3Γ—; a non-app image declines. The Claude-vision adapter needs `@anthropic-ai/sdk` installed separately; the stub and matching helpers do not. Images are processed in memory, never persisted. + +## What it understands + +Endpoints (constants, templates, API wrappers, react-query/SWR), i18n text, cross-file instance trees & per-instance prop-flow, Redux/Zustand stores, portals/modals/toasts, React Router & Next.js routes, action effects (navigate/fetch/dispatch/setState), form libraries & non-JSX events (react-hook-form, `addEventListener`, hotkeys), and feature-flag/role conditions. + +## New in 0.4.0 + +Field-hardening from real-codebase validation (React 18 Β· Redux Toolkit Β· RTK Query Β· MUI Β· React Router): + +- **Matcher fix** β€” rendered text that normalizes to empty (`|`, `/`, `-`) no longer acts as a universal wildcard; gibberish now declines `no-signal` instead of returning false high-confidence matches. +- **Instance resolution** β€” tsconfig-path aliases (`@ui`), multi-hop rename barrels, and `Loadable(lazy(() => import()))` page wrappers now resolve to their definitions, so `blast_radius` and the render graph are complete. +- **RTK Query** β€” `createApi` / `injectEndpoints` / `builder.query|mutation` become data sources (baseUrl-joined, `:param`-normalized); generated hooks (`useGetUsersQuery`) wire per-component `fetches-from` edges. +- **Object-config routes** β€” `createBrowserRouter` with an imported/spread-composed config and lazy-wrapped elements now emits route nodes, so `journeys("/path")` works. +- **Scoring** β€” terms that also name a component (name/props/file) outrank incidental text; every candidate carries a top-line `score` next to `confidence`. +- **`visualize`** β€” new command renders the graph as a self-contained interactive HTML galaxy (`ui-lineage visualize -g app.graph.json -o app.galaxy.html`). + +## New in 0.3.0 + +The agent interface: `resolve`/`bundle` produce a budgeted **context bundle** (match β†’ lineage β†’ journeys β†’ blast radius β†’ tests β†’ response types β†’ git history) Β· `impact` blast-radius traversal Β· test-coverage mapping Β· response-schema linking (generic / annotation / OpenAPI, via `scan --openapi`) Β· and the **`ui-lineage-mcp`** MCP server exposing `resolve_context` Β· `find_component` Β· `trace_lineage` Β· `journeys` Β· `blast_radius` over stdio. + +### Previously (0.2.0) + +User journeys Β· action effects Β· form & non-JSX events Β· flag/role conditions Β· a real matching engine (rarity + fuzzy/OCR + structural + most-specific-subtree) Β· screenshot/vision adapter Β· alias glossary + corrections store Β· calibrated confidence with honesty metrics. + +## Status + +Pre-1.0. Output is deterministic and language-agnostic (a plain JSON graph), designed to feed AI agents as a context provider β€” not to be one. Next: lifecycle/scale hardening (incremental scan, caching) and backend lineage (pixel β†’ API handler). + +## License + +MIT diff --git a/packages/cli/package.json b/packages/cli/package.json index 1aeae1f..972f3ea 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,26 +1,67 @@ { - "name": "@coderadar/cli", - "version": "0.1.0", - "description": "CodeRadar CLI β€” scan a React codebase into a lineage graph and query it.", + "name": "ui-lineage", + "version": "0.4.0", + "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", + "ui-lineage-mcp": "dist/mcp.js" + }, + "main": "dist/lib.js", + "module": "dist/lib.js", + "types": "dist/lib.d.ts", + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "default": "./dist/lib.js" + }, + "./vision": { + "types": "./dist/vision.d.ts", + "default": "./dist/vision.js" + }, + "./package.json": "./package.json" }, "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", + "test": "vitest run", + "prepublishOnly": "pnpm build" }, "dependencies": { - "@coderadar/core": "workspace:*", - "@coderadar/parser-react": "workspace:*", - "commander": "^13.0.0" + "@modelcontextprotocol/sdk": "^1.12.0", + "commander": "^13.0.0", + "ts-morph": "^24.0.0", + "yaml": "^2.9.0", + "zod": "^3.23.8" }, "devDependencies": { + "@coderadar/agent-sdk": "workspace:*", + "@coderadar/core": "workspace:*", + "@coderadar/mcp": "workspace:*", + "@coderadar/parser-react": "workspace:*", + "@coderadar/vision": "workspace:*", "@types/node": "^22.20.1", - "typescript": "^5.7.0" + "tsup": "^8.5.1", + "typescript": "^5.7.0", + "vitest": "^3.2.7" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 20818f2..372d3b2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,37 +3,64 @@ import fs from "node:fs"; import path from "node:path"; import { + blastRadius, + type Candidate, + collectGraphMeta, + type ComponentMatch, + type ImpactNode, + type JourneyPath, + journeys, type LineageGraph, - matchComponentsByText, + loadCorrections, + loadGraph as loadGraphFile, + matchComponents, + recordCorrection, + saveGraph, traceLineage, } from "@coderadar/core"; +import { buildBundle, resolveContext } from "@coderadar/agent-sdk"; import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; import { Command } from "commander"; +import { parse as parseYaml } from "yaml"; + +import { renderVisualization } from "./visualize.js"; 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"); + .version("0.4.0"); 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") - .action((dir: string, opts: { out: string }) => { - const graph = resolveHookEdges(scanReact({ root: dir })); - fs.writeFileSync(opts.out, JSON.stringify(graph, null, 2)); + .option("-o, --out ", "output file", "ui-lineage.graph.json") + .option("--openapi ", "OpenAPI 3 JSON spec (relative to ) for response-schema linking") + .action((dir: string, opts: { out: string; openapi?: string }) => { + const meta = collectGraphMeta(path.resolve(dir)); + const graph = { + ...resolveHookEdges(scanReact({ root: dir, ...(opts.openapi ? { openapi: opts.openapi } : {}) })), + 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)}`); - for (const [kind, count] of counts) console.log(` ${kind}: ${count}`); + 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}`); + 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}`); }); @@ -41,27 +68,40 @@ 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") - .action((terms: string[], opts: { graph: string }) => { + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-a, --aliases ", "business-vocab glossary (aliases.yaml)") + .option("-c, --corrections ", "corrections store (jsonl)", "ui-lineage.corrections.jsonl") + .action((terms: string[], opts: { graph: string; aliases?: string; corrections: string }) => { const graph = loadGraph(opts.graph); - const matches = matchComponentsByText(graph, terms); - if (matches.length === 0) { - console.log("No components matched."); + const aliases = + opts.aliases !== undefined && fs.existsSync(opts.aliases) + ? (parseYaml(fs.readFileSync(opts.aliases, "utf-8")) as Record) + : undefined; + const corrections = fs.existsSync(opts.corrections) + ? loadCorrections(opts.corrections) + : undefined; + const result = matchComponents(graph, { + terms, + ...(aliases !== undefined ? { aliases } : {}), + ...(corrections !== undefined ? { corrections } : {}), + }); + 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") - .option("-g, --graph ", "graph file", "coderadar.graph.json") + .argument("", "component name, definition id, or instance id") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") .action((component: string, opts: { graph: string }) => { const graph = loadGraph(opts.graph); const node = @@ -72,17 +112,23 @@ 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) { console.log(` [${ds.sourceKind}] ${ds.method ?? "?"} ${ds.endpoint} (${ds.loc.file}:${ds.loc.line})`); + if (ds.responseType !== undefined) { + const fields = ds.responseType.fields.map((f) => `${f.name}: ${f.type}`).join(", "); + console.log(` β†’ ${ds.responseType.name} { ${fields} } (${ds.responseType.source})`); + } } } if (lineage.state.length > 0) { @@ -98,16 +144,214 @@ 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(", ")}`); + } + if (lineage.perInstance !== undefined && lineage.perInstance.length > 0) { + console.log(" per instance:"); + for (const inst of lineage.perInstance) { + console.log( + ` ${inst.instance.name}@${inst.instance.loc.file}:${inst.instance.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})`); + } + } + } + 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", "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); + 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); + }); + +program + .command("resolve") + .description("Resolve a ticket: classify its entry point, then match it to component(s)") + .argument("", "the ticket text") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-s, --screenshot", "the ticket has a screenshot attached") + .action((text: string, opts: { graph: string; screenshot?: boolean }) => { + const graph = loadGraph(opts.graph); + const result = resolveContext(graph, { + text, + ...(opts.screenshot ? { screenshots: 1 } : {}), + }); + console.log(`entry point: ${result.entryPoint} β€” ${result.classification.reason}`); + if (result.decline !== undefined) { + console.log(`declined (${result.decline.reason}): ${result.decline.message}`); + return; + } + const match = result.match; + if (match === undefined || match.status === "declined") { + console.log(`no component matched (${match?.declineReason ?? "no result"}).`); + return; + } + if (match.status === "ambiguous") console.log(`ambiguous β€” ${match.disambiguation}\n`); + for (const candidate of match.candidates.slice(0, 5)) printMatchCandidate(candidate); + }); + +program + .command("bundle") + .description("Resolve a ticket into a budgeted context bundle (JSON) for an agent") + .argument("", "the ticket text") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-s, --screenshot", "the ticket has a screenshot attached") + .option("-b, --budget ", "token budget", "4000") + .action((text: string, opts: { graph: string; screenshot?: boolean; budget: string }) => { + const graph = loadGraph(opts.graph); + const budgetTokens = Number.parseInt(opts.budget, 10); + const bundle = buildBundle( + graph, + { text, ...(opts.screenshot ? { screenshots: 1 } : {}) }, + { budgetTokens: Number.isNaN(budgetTokens) ? 4000 : budgetTokens }, + ); + console.log(JSON.stringify(bundle, null, 2)); }); +program + .command("impact") + .description("Blast radius: everything that depends on a node (reverse traversal)") + .argument("", "node id, component name, API endpoint, state name, or route path") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-d, --depth ", "max dependency hops", "0") + .action((node: string, opts: { graph: string; depth: string }) => { + const graph = loadGraph(opts.graph); + const depth = Number.parseInt(opts.depth, 10); + const result = blastRadius( + graph, + node, + depth > 0 ? { depth } : {}, + ); + if (result.status === "declined" || result.candidates[0] === undefined) { + console.error(`Node not found: ${node} (${result.declineReason ?? "no result"}).`); + process.exitCode = 1; + return; + } + const impacts = result.candidates[0].value; + if (impacts.length === 0) { + console.log(`Nothing depends on ${node}.`); + return; + } + console.log(`${impacts.length} node(s) affected by changing ${node}:\n`); + for (const impact of impacts) printImpact(impact); + }); + +program + .command("correct") + .description("Record that some on-screen text means a component β€” feeds future `find` results") + .argument("", "component name it should resolve to") + .argument("", "the text fragments that mean it") + .option("-c, --corrections ", "corrections store (jsonl)", "ui-lineage.corrections.jsonl") + .action((component: string, terms: string[], opts: { corrections: string }) => { + recordCorrection(opts.corrections, { terms, component }); + console.log(`Recorded: [${terms.join(", ")}] β†’ ${component} (${opts.corrections})`); + }); + +program + .command("visualize") + .description("Render the lineage graph as a self-contained interactive HTML galaxy") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-o, --out ", "output HTML file", "ui-lineage.galaxy.html") + .option("-t, --title ", "page title") + .action((opts: { graph: string; out: string; title?: string }) => { + const graph = loadGraph(opts.graph); + const html = renderVisualization(graph, opts.title); + fs.writeFileSync(opts.out, html); + const kb = (Buffer.byteLength(html) / 1024).toFixed(0); + console.log( + `Galaxy written to ${opts.out} (${kb} KB, ${graph.nodes.length} nodes / ${graph.edges.length} edges).`, + ); + console.log(`Open it in a browser: open ${opts.out}`); + }); + +const STEP_ARROW: Record<string, string> = { + page: "β–Έ", + event: "β€’", + navigate: "β†’", + fetch: "β‡’", + "state-write": "✎", + exit: "⏏", +}; + +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 printImpact(impact: ImpactNode): void { + const node = impact.node; + const name = + node.kind === "data-source" + ? node.endpoint + : node.kind === "route" + ? node.path + : node.kind === "event" + ? (node.handler ?? node.event) + : node.name; + const indent = " ".repeat(impact.distance); + console.log( + `${indent}[${impact.relation}] ${node.kind} ${name} (${node.loc.file}:${node.loc.line})`, + ); +} + +function printMatchCandidate(candidate: Candidate<ComponentMatch>): void { + const match = candidate.value; + console.log( + `${match.component.name} (${match.component.loc.file}:${match.component.loc.line}) ` + + `score=${candidate.score?.toFixed(2) ?? "β€”"} ` + + `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 <dir>\` first.`); + console.error(`Graph file not found: ${file} β€” run \`ui-lineage scan <dir>\` first.`); + process.exit(1); + } + try { + return loadGraphFile(file); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } - return JSON.parse(fs.readFileSync(file, "utf-8")) as LineageGraph; } program.parse(); diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts new file mode 100644 index 0000000..8bdb480 --- /dev/null +++ b/packages/cli/src/lib.ts @@ -0,0 +1,20 @@ +/** + * 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"; +export { + buildBundle, + classifyTicket, + type ContextBundle, + type ContextResult, + type EntryPoint, + resolveContext, + type Ticket, +} from "@coderadar/agent-sdk"; diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts new file mode 100644 index 0000000..09a802d --- /dev/null +++ b/packages/cli/src/mcp.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * `ui-lineage-mcp` β€” the CodeRadar MCP server as a self-contained bin. + * + * Serves a pre-built lineage graph over stdio so an MCP-speaking agent can call + * resolve_context / find_component / trace_lineage / journeys / blast_radius. + * Build the graph first: `ui-lineage scan <dir> -o app.graph.json`, then point a + * client at `ui-lineage-mcp` with CODERADAR_GRAPH=app.graph.json (or pass the + * path as the first argument). + */ + +import { loadGraph } from "@coderadar/core"; +import { createServer } from "@coderadar/mcp"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +async function main(): Promise<void> { + const graphPath = process.env["CODERADAR_GRAPH"] ?? process.argv[2]; + if (graphPath === undefined) { + process.stderr.write( + "ui-lineage-mcp: set CODERADAR_GRAPH (or pass a path) to a graph built with `ui-lineage scan`.\n", + ); + process.exit(1); + } + let graph; + try { + graph = loadGraph(graphPath); + } catch (error) { + process.stderr.write(`ui-lineage-mcp: cannot load ${graphPath}: ${String(error)}\n`); + process.exit(1); + } + const server = createServer(graph); + await server.connect(new StdioServerTransport()); + process.stderr.write(`ui-lineage-mcp ready β€” ${graph.nodes.length} nodes from ${graphPath}\n`); +} + +main().catch((error: unknown) => { + process.stderr.write(`ui-lineage-mcp: fatal ${String(error)}\n`); + process.exit(1); +}); diff --git a/packages/cli/src/vision.ts b/packages/cli/src/vision.ts new file mode 100644 index 0000000..8ba6621 --- /dev/null +++ b/packages/cli/src/vision.ts @@ -0,0 +1,6 @@ +/** + * ui-lineage/vision β€” screenshot β†’ { terms, structure, annotations } adapters. + * Live Claude extraction needs `@anthropic-ai/sdk` installed separately; the + * stub adapter and matching helpers work with no extra dependency. + */ +export * from "@coderadar/vision"; diff --git a/packages/cli/src/visualize.test.ts b/packages/cli/src/visualize.test.ts new file mode 100644 index 0000000..24b3c43 --- /dev/null +++ b/packages/cli/src/visualize.test.ts @@ -0,0 +1,87 @@ +import type { LineageGraph } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { renderVisualization, toViewModel } from "./visualize.js"; + +const graph: LineageGraph = { + version: 2, + root: "/app", + generatedAt: "2026-07-15T00:00:00.000Z", + generator: "test", + nodes: [ + { + id: "component:App.tsx#App", + kind: "component", + name: "App", + loc: { file: "App.tsx", line: 1, endLine: 9 }, + exportName: "App", + // A prop carrying a </script> payload β€” it flows into the node detail + // and thus the embedded JSON, so it exercises the breakout guard. + props: ["title", "</script><b>x"], + renderedText: [{ text: "Hello world", source: "jsx" }], + rendersComponents: [], + structure: { + table: 0, columns: 0, form: 0, input: 0, button: 0, + link: 0, image: 0, heading: 1, list: 0, repeated: 0, + }, + }, + { + id: "data-source:api.ts#rtk-query:/api/users", + kind: "data-source", + name: "/api/users", + loc: { file: "api.ts", line: 4, endLine: 4 }, + sourceKind: "rtk-query", + method: "GET", + endpoint: "/api/users", + raw: '"/users"', + resolved: "full", + }, + ], + edges: [ + { from: "component:App.tsx#App", to: "data-source:api.ts#rtk-query:/api/users", kind: "fetches-from" }, + // Dangling edge β€” must be dropped, not rendered. + { from: "component:App.tsx#App", to: "component:Ghost.tsx#Ghost", kind: "renders" }, + ], +}; + +describe("visualize view model (TRACKER 6F.8)", () => { + it("trims nodes and drops edges to unknown endpoints", () => { + const model = toViewModel(graph); + expect(model.nodes).toHaveLength(2); + expect(model.edges).toHaveLength(1); + expect(model.edges[0]?.kind).toBe("fetches-from"); + }); + + it("summarizes each node kind for the detail panel", () => { + const model = toViewModel(graph); + const ds = model.nodes.find((n) => n.kind === "data-source"); + expect(ds?.detail).toBe("GET /api/users (rtk-query)"); + expect(ds?.label).toBe("/api/users"); + }); +}); + +describe("visualize HTML output", () => { + const html = renderVisualization(graph, "My Galaxy"); + + it("is a self-contained document with no external requests", () => { + expect(html.startsWith("<!doctype html>")).toBe(true); + expect(html).toContain("<title>My Galaxy"); + // No network dependencies: no external src/href/fetch of remote origins. + expect(html).not.toMatch(/src="https?:/); + expect(html).not.toMatch(/href="https?:/); + expect(html).not.toMatch(/@import/); + }); + + it("embeds the graph JSON safely against breakout", () => { + // A prop carries "" β€” the embed must escape it so it can't close + // the data " can never break out of the tag. + */ +function embedJson(value: unknown): string { + return JSON.stringify(value).replace(/>((acc, n) => { + acc[n.kind] = (acc[n.kind] ?? 0) + 1; + return acc; + }, {}); + const summary = + `${model.nodes.length} nodes Β· ${model.edges.length} edges` + + (graph.generatedAt !== undefined ? ` Β· scanned ${graph.generatedAt}` : ""); + + return ` + + + + +${escapeHtml(title)} + + + +
+ + +
+
+ + + +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] ?? c); +} + +/** Palette + physics constants shared between server (legend counts) and client. */ +const KIND_COLORS: Record = { + component: "#4f9dff", + instance: "#7ee0c8", + hook: "#c58bff", + "data-source": "#ff8f6b", + state: "#ffd24d", + event: "#ff6ba3", + route: "#9be15d", + external: "#9aa5b1", + test: "#6bd0ff", +}; + +const STYLE = ` +:root { color-scheme: dark; } +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0e14; color: #d7dce5; } +#app { display: flex; height: 100vh; } +#sidebar { width: 300px; flex: none; padding: 16px; overflow-y: auto; background: #11151f; border-right: 1px solid #1e2530; } +#sidebar h1 { font-size: 15px; margin: 0 0 2px; } +.summary { color: #7c8698; margin: 0 0 12px; font-size: 12px; } +#search { width: 100%; padding: 7px 9px; border-radius: 7px; border: 1px solid #2a3342; background: #0b0e14; color: inherit; margin-bottom: 14px; } +.controls { border-top: 1px solid #1e2530; padding: 12px 0; } +.control-head { display: flex; justify-content: space-between; align-items: center; font-weight: 600; color: #9aa5b1; margin: 6px 0; text-transform: uppercase; font-size: 10px; letter-spacing: .06em; } +.mini { background: none; border: 1px solid #2a3342; color: #9aa5b1; border-radius: 5px; padding: 1px 7px; cursor: pointer; font-size: 11px; } +.mini:hover { color: #fff; border-color: #3a4658; } +.filter { display: flex; align-items: center; gap: 7px; padding: 3px 0; cursor: pointer; user-select: none; } +.filter .swatch { width: 11px; height: 11px; border-radius: 3px; flex: none; } +.filter.off { opacity: .38; } +.filter .count { margin-left: auto; color: #6b7484; font-variant-numeric: tabular-nums; } +.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; } +button.wide, button.reset { width: 100%; } +button.wide { margin-top: 8px; padding: 7px; border-radius: 7px; border: 1px solid #2a3342; background: #0b0e14; color: inherit; cursor: pointer; } +button.wide:hover { border-color: #3a4658; } +.detail { border-top: 1px solid #1e2530; padding-top: 12px; margin-top: 4px; } +.detail.empty { color: #6b7484; } +.detail .dname { font-size: 14px; font-weight: 600; margin-bottom: 2px; word-break: break-word; } +.detail .dkind { display: inline-block; padding: 1px 8px; border-radius: 20px; font-size: 11px; color: #0b0e14; font-weight: 600; margin-bottom: 8px; } +.detail .drow { color: #9aa5b1; margin: 3px 0; word-break: break-word; } +.detail .drow b { color: #d7dce5; font-weight: 600; } +.detail .dflag { display: inline-block; background: #3a2530; color: #ff8f6b; border-radius: 4px; padding: 0 6px; font-size: 11px; margin: 2px 4px 0 0; } +.hint { color: #5a6474; font-size: 11px; margin-top: 12px; } +#galaxy { flex: 1; display: block; cursor: grab; } +#galaxy:active { cursor: grabbing; } +#legend { position: fixed; bottom: 12px; right: 12px; background: rgba(17,21,31,.85); border: 1px solid #1e2530; border-radius: 8px; padding: 8px 10px; font-size: 11px; pointer-events: none; } +#legend div { display: flex; align-items: center; gap: 6px; padding: 1px 0; } +#legend .swatch { width: 9px; height: 9px; border-radius: 2px; } +`; + +/** The inlined client: force sim + canvas render + interactions. */ +function clientScript(counts: Record): string { + return ` +const KIND_COLORS = ${JSON.stringify(KIND_COLORS)}; +const KIND_COUNTS = ${JSON.stringify(counts)}; +const model = JSON.parse(document.getElementById("graph-data").textContent); +const canvas = document.getElementById("galaxy"); +const ctx = canvas.getContext("2d"); +const dpr = Math.min(window.devicePixelRatio || 1, 2); + +// ---- state ---- +const nodeById = new Map(); +const nodes = model.nodes.map((n, i) => { + const angle = i * 2.399963229728653; // golden angle β†’ even initial spread + const radius = 30 * Math.sqrt(i); + const node = { ...n, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, vx: 0, vy: 0, pinned: false, deg: 0 }; + nodeById.set(n.id, node); + return node; +}); +const edges = model.edges.filter((e) => nodeById.has(e.from) && nodeById.has(e.to)); +for (const e of edges) { nodeById.get(e.from).deg++; nodeById.get(e.to).deg++; } +const adjacency = new Map(nodes.map((n) => [n.id, new Set()])); +for (const e of edges) { adjacency.get(e.from).add(e.to); adjacency.get(e.to).add(e.from); } + +const kindsOn = new Set(Object.keys(KIND_COLORS)); +const edgeKinds = [...new Set(edges.map((e) => e.kind))].sort(); +const edgeKindsOn = new Set(edgeKinds); +let selected = null; +let physicsOn = true; +let alwaysLabels = false; +const view = { x: 0, y: 0, scale: 0.7 }; + +// ---- filters UI ---- +const kindWrap = document.getElementById("kind-filters"); +for (const kind of Object.keys(KIND_COLORS)) { + if (!(kind in KIND_COUNTS)) continue; + const el = document.createElement("div"); + el.className = "filter"; + el.innerHTML = '' + kind + '' + KIND_COUNTS[kind] + ''; + el.onclick = () => { kindsOn.has(kind) ? kindsOn.delete(kind) : kindsOn.add(kind); el.classList.toggle("off"); }; + kindWrap.appendChild(el); +} +const edgeWrap = document.getElementById("edge-filters"); +for (const kind of edgeKinds) { + const el = document.createElement("div"); + el.className = "filter"; + el.innerHTML = '' + kind; + el.onclick = () => { edgeKindsOn.has(kind) ? edgeKindsOn.delete(kind) : edgeKindsOn.add(kind); el.classList.toggle("off"); }; + edgeWrap.appendChild(el); +} +const legend = document.getElementById("legend"); +for (const kind of Object.keys(KIND_COLORS)) { + if (!(kind in KIND_COUNTS)) continue; + const d = document.createElement("div"); + d.innerHTML = '' + kind; + legend.appendChild(d); +} +function toggleAll(set, all, btn, refresh) { + return () => { + const turnOn = set.size < all.length; + set.clear(); + if (turnOn) for (const k of all) set.add(k); + refresh(); + }; +} +document.getElementById("toggle-nodes").onclick = toggleAll(kindsOn, Object.keys(KIND_COUNTS), null, + () => [...kindWrap.children].forEach((el, i) => el.classList.toggle("off", !kindsOn.has(Object.keys(KIND_COUNTS)[i])))); +document.getElementById("toggle-edges").onclick = toggleAll(edgeKindsOn, edgeKinds, null, + () => [...edgeWrap.children].forEach((el, i) => el.classList.toggle("off", !edgeKindsOn.has(edgeKinds[i])))); +document.getElementById("physics").onchange = (e) => { physicsOn = e.target.checked; }; +document.getElementById("labels").onchange = (e) => { alwaysLabels = e.target.checked; }; +document.getElementById("reset-view").onclick = () => { view.scale = 0.7; recenter(); }; + +// ---- force simulation (grid-approximated repulsion β†’ scales to thousands) ---- +const REPULSION = 1400, SPRING = 0.008, SPRING_LEN = 60, GRAVITY = 0.02, DAMPING = 0.9, CELL = 90; +function tick() { + if (!physicsOn) return; + const grid = new Map(); + const key = (x, y) => Math.floor(x / CELL) + "," + Math.floor(y / CELL); + for (const n of nodes) { const k = key(n.x, n.y); (grid.get(k) || grid.set(k, []).get(k)).push(n); } + for (const n of nodes) { + if (n.pinned) continue; + const cx = Math.floor(n.x / CELL), cy = Math.floor(n.y / CELL); + for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) { + for (const m of grid.get(gx + "," + gy) || []) { + if (m === n) continue; + let dx = n.x - m.x, dy = n.y - m.y, d2 = dx * dx + dy * dy || 0.01; + if (d2 > CELL * CELL * 4) continue; + const f = REPULSION / d2, d = Math.sqrt(d2); + n.vx += (dx / d) * f; n.vy += (dy / d) * f; + } + } + n.vx -= n.x * GRAVITY; n.vy -= n.y * GRAVITY; + } + for (const e of edges) { + const a = nodeById.get(e.from), b = nodeById.get(e.to); + let dx = b.x - a.x, dy = b.y - a.y, d = Math.sqrt(dx * dx + dy * dy) || 0.01; + const f = (d - SPRING_LEN) * SPRING; + const fx = (dx / d) * f, fy = (dy / d) * f; + if (!a.pinned) { a.vx += fx; a.vy += fy; } + if (!b.pinned) { b.vx -= fx; b.vy -= fy; } + } + for (const n of nodes) { + if (n.pinned) continue; + n.vx *= DAMPING; n.vy *= DAMPING; + n.x += Math.max(-8, Math.min(8, n.vx)); n.y += Math.max(-8, Math.min(8, n.vy)); + } +} + +// ---- render ---- +function nodeRadius(n) { return 3 + Math.min(7, Math.sqrt(n.deg)); } +function visible(n) { return kindsOn.has(n.kind); } +function draw() { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.translate(canvas.width / (2 * dpr) + view.x, canvas.height / (2 * dpr) + view.y); + ctx.scale(view.scale, view.scale); + + const hi = selected ? adjacency.get(selected.id) : null; + ctx.lineWidth = 1 / view.scale; + for (const e of edges) { + if (!edgeKindsOn.has(e.kind)) continue; + const a = nodeById.get(e.from), b = nodeById.get(e.to); + if (!visible(a) || !visible(b)) continue; + const near = selected && (e.from === selected.id || e.to === selected.id); + if (selected && !near) { ctx.globalAlpha = 0.05; ctx.strokeStyle = "#2a3342"; } + else { ctx.globalAlpha = selected ? 0.9 : 0.28; ctx.strokeStyle = near ? "#8fa3c0" : "#3a4658"; } + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + } + ctx.globalAlpha = 1; + for (const n of nodes) { + if (!visible(n)) continue; + const dim = selected && n !== selected && !(hi && hi.has(n.id)); + ctx.globalAlpha = dim ? 0.18 : 1; + ctx.fillStyle = KIND_COLORS[n.kind] || "#888"; + ctx.beginPath(); ctx.arc(n.x, n.y, nodeRadius(n), 0, 6.283); ctx.fill(); + if (n === selected) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 2 / view.scale; ctx.stroke(); } + if ((alwaysLabels || n === selected || (hi && hi.has(n.id)) || (view.scale > 1.4 && n.deg > 2)) && !dim) { + ctx.globalAlpha = 1; ctx.fillStyle = "#d7dce5"; ctx.font = (11 / view.scale) + "px sans-serif"; + ctx.fillText(n.label, n.x + nodeRadius(n) + 2 / view.scale, n.y + 3 / view.scale); + } + } + ctx.restore(); + ctx.globalAlpha = 1; +} +function frame() { tick(); draw(); requestAnimationFrame(frame); } + +// ---- interaction ---- +function resize() { canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; } +function recenter() { view.x = 0; view.y = 0; } +window.addEventListener("resize", resize); +function toWorld(px, py) { + return { x: (px - canvas.clientWidth / 2 - view.x) / view.scale, y: (py - canvas.clientHeight / 2 - view.y) / view.scale }; +} +function hitTest(px, py) { + const w = toWorld(px, py); + let best = null, bestD = Infinity; + for (const n of nodes) { + if (!visible(n)) continue; + const dx = n.x - w.x, dy = n.y - w.y, d = dx * dx + dy * dy, r = nodeRadius(n) + 4; + if (d < r * r && d < bestD) { best = n; bestD = d; } + } + return best; +} +let drag = null, dragMoved = false, panning = false, lastX = 0, lastY = 0; +canvas.addEventListener("mousedown", (e) => { + const hit = hitTest(e.offsetX, e.offsetY); + dragMoved = false; + if (hit) { drag = hit; hit.pinned = true; } else { panning = true; } + lastX = e.offsetX; lastY = e.offsetY; +}); +window.addEventListener("mousemove", (e) => { + if (drag) { + const w = toWorld(e.offsetX, e.offsetY); drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; dragMoved = true; + } else if (panning) { + view.x += e.offsetX - lastX; view.y += e.offsetY - lastY; lastX = e.offsetX; lastY = e.offsetY; + } +}); +window.addEventListener("mouseup", (e) => { + if (drag && !dragMoved) { drag.pinned = false; select(drag); } + else if (panning && Math.abs(e.offsetX - lastX) < 3) { /* click empty β†’ clear */ if (!drag) select(null); } + drag = null; panning = false; +}); +canvas.addEventListener("wheel", (e) => { + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + view.scale = Math.max(0.1, Math.min(6, view.scale * factor)); +}, { passive: false }); + +function select(n) { + selected = n; + const panel = document.getElementById("detail"); + if (!n) { panel.className = "detail empty"; panel.textContent = "Click a node to inspect it."; return; } + panel.className = "detail"; + const color = KIND_COLORS[n.kind] || "#888"; + const flags = n.flags.map((f) => '' + f + '').join(""); + panel.innerHTML = + '
' + esc(n.label) + '
' + + '' + n.kind + '' + + '
' + esc(n.detail) + '
' + + '
' + esc(n.file) + ':' + n.line + '
' + + '
' + n.deg + ' connection' + (n.deg === 1 ? '' : 's') + '
' + + (flags ? '
' + flags + '
' : ''); +} +function esc(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); } + +// search β†’ fly to first match +const search = document.getElementById("search"); +search.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + const q = search.value.trim().toLowerCase(); + if (!q) return; + const hit = nodes.find((n) => n.label.toLowerCase().includes(q) || n.file.toLowerCase().includes(q)); + if (hit) { view.scale = 1.6; view.x = -hit.x * view.scale; view.y = -hit.y * view.scale; select(hit); } +}); + +resize(); +frame(); +`; +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..63d5d68 --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,23 @@ +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) + mcp: "src/mcp.ts", // ui-lineage-mcp bin (MCP stdio server) + lib: "src/lib.ts", // library entry + vision: "src/vision.ts", // ui-lineage/vision subpath + }, + 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\//], + // @anthropic-ai/sdk is a lazy, optional runtime dep of the vision adapter. + external: ["ts-morph", "yaml", "commander", "@anthropic-ai/sdk", "@modelcontextprotocol/sdk", "zod"], +}); diff --git a/packages/core/package.json b/packages/core/package.json index e8697e3..7fe8592 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.", + "version": "0.4.0", + "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", @@ -17,9 +18,14 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "schema": "node scripts/gen-schema.mjs" }, "devDependencies": { - "typescript": "^5.7.0" + "@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/corrections.ts b/packages/core/src/corrections.ts new file mode 100644 index 0000000..ed5b90d --- /dev/null +++ b/packages/core/src/corrections.ts @@ -0,0 +1,26 @@ +/** + * The corrections store (TRACKER step 4.6, failure mode G4). + * + * When a human confirms that a set of terms means a specific component, that + * confirmation is appended to a `corrections.jsonl` file (one JSON object per + * line) and fed back to the matcher as the highest-weight evidence, so the next + * identical query resolves the same way. Terms only β€” never screenshots (G7). + */ +import fs from "node:fs"; + +import type { Correction } from "./query.js"; + +/** Read all corrections from a JSONL file. Missing file β†’ empty list. */ +export function loadCorrections(path: string): Correction[] { + if (!fs.existsSync(path)) return []; + return fs + .readFileSync(path, "utf-8") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Correction); +} + +/** Append one correction to the JSONL store. */ +export function recordCorrection(path: string, correction: Correction): void { + fs.appendFileSync(path, `${JSON.stringify(correction)}\n`); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fa7ae12..b8f269a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,6 @@ export * from "./types.js"; +export * from "./result.js"; export * from "./query.js"; +export * from "./corrections.js"; +export * from "./storage.js"; +export * from "./text.js"; 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/matching.test.ts b/packages/core/src/matching.test.ts new file mode 100644 index 0000000..0551bca --- /dev/null +++ b/packages/core/src/matching.test.ts @@ -0,0 +1,229 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { loadCorrections, recordCorrection } from "./corrections.js"; +import { matchComponents, 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("the disambiguation question names each leader's distinctive text (D6)", () => { + const result = matchComponentsByText(graph(forms), ["Save"]); + expect(result.status).toBe("ambiguous"); + // Built from the DIFFERENCES: each tied candidate's unique text. + expect(result.disambiguation).toContain("Card number"); + expect(result.disambiguation).toContain("Notifications"); + expect(result.disambiguation).toContain("Display name"); + }); + + 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"); + }); + + it("caps a structure-only match at medium confidence, never high (A3/A12)", () => { + const chart: ComponentNode = { + ...component("MetricsChart", []), + structure: { + table: 0, + columns: 0, + form: 0, + input: 0, + button: 2, + link: 0, + image: 0, + heading: 0, + list: 0, + repeated: 0, + }, + }; + const result = matchComponents(graph([chart]), { structure: { buttons: 2 } }); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("MetricsChart"); + expect(result.candidates[0]?.confidence.level).toBe("medium"); + }); +}); + +describe("alias glossary & corrections (TRACKER 4.6, E2/G4)", () => { + const g = graph([ + component("BillingSummaryCard", ["Billing summary", "Amount due"]), + component("InvoiceList", ["Recent invoices"]), + ]); + + it("resolves a business-vocab phrase that appears nowhere in the code", () => { + expect(matchComponentsByText(g, ["invoice widget"]).status).toBe("declined"); + const withAlias = matchComponents(g, { + terms: ["invoice widget"], + aliases: { "invoice widget": "BillingSummaryCard" }, + }); + expect(withAlias.status).toBe("ok"); + expect(withAlias.candidates[0]?.value.component.name).toBe("BillingSummaryCard"); + expect(withAlias.candidates[0]?.evidence.some((e) => e.kind === "alias")).toBe(true); + }); + + it("a recorded correction overrides an otherwise-correct text match", () => { + const before = matchComponents(g, { terms: ["Recent invoices"] }); + expect(before.candidates[0]?.value.component.name).toBe("InvoiceList"); + const after = matchComponents(g, { + terms: ["Recent invoices"], + corrections: [{ terms: ["Recent invoices"], component: "BillingSummaryCard" }], + }); + expect(after.candidates[0]?.value.component.name).toBe("BillingSummaryCard"); + expect(after.candidates[0]?.evidence.some((e) => e.kind === "correction")).toBe(true); + }); + + const tmpFiles: string[] = []; + afterEach(() => { + for (const f of tmpFiles.splice(0)) fs.rmSync(f, { force: true }); + }); + + it("round-trips corrections through a JSONL store; the next query flips top-1", () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "uil-")), "corrections.jsonl"); + tmpFiles.push(file); + expect(loadCorrections(file)).toEqual([]); + recordCorrection(file, { terms: ["Recent invoices"], component: "BillingSummaryCard" }); + const corrections = loadCorrections(file); + const result = matchComponents(g, { terms: ["Recent invoices"], corrections }); + expect(result.candidates[0]?.value.component.name).toBe("BillingSummaryCard"); + }); +}); + +describe("empty-normalizing rendered text is never a wildcard (TRACKER 6F.1, A14)", () => { + // The field-found v0.3.0 bug: "|" normalizes to "", and an empty target is a + // substring of every query β€” so every call matched the same punctuation + // components, ranked only by the query's own character rarity. + const g = graph([ + component("Divider", ["|"]), + component("Breadcrumb", ["/", "-"]), + component("CalendarPanel", ["Upcoming meetings", "Calendar"]), + ]); + + it("gibberish declines no-signal instead of matching punctuation components", () => { + const result = matchComponentsByText(g, ["zzqwxnomatch12345"]); + expect(result.status).toBe("declined"); + expect(result.declineReason).toBe("no-signal"); + expect(result.candidates).toEqual([]); + }); + + it("a real term ranks the real component without punctuation noise", () => { + const result = matchComponentsByText(g, ["Upcoming meetings"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("CalendarPanel"); + const names = result.candidates.map((c) => c.value.component.name); + expect(names).not.toContain("Divider"); + expect(names).not.toContain("Breadcrumb"); + }); + + it("mixing gibberish with a real term still resolves to the real component", () => { + const result = matchComponentsByText(g, ["zzqwxnomatch12345", "Upcoming meetings"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("CalendarPanel"); + }); +}); + +describe("identifier affinity & top-line score (TRACKER 6F.7)", () => { + // "Calendar" is rendered by both, but only CalendarPanel is NAMED for it β€” + // global character rarity alone can't tell these apart (field complaint). + const g = graph([ + component("CalendarPanel", ["Calendar", "Upcoming meetings"]), + component("Dashboard", ["Calendar", "Revenue overview"]), + ]); + + it("a term that also names the component beats the same text elsewhere", () => { + const result = matchComponentsByText(g, ["Calendar"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("CalendarPanel"); + expect( + result.candidates[0]?.evidence.some((e) => e.detail.includes("also names the component")), + ).toBe(true); + }); + + it("exposes the raw ranking score at the candidate top level, best-first", () => { + const result = matchComponentsByText(g, ["Calendar"]); + const scores = result.candidates.map((c) => c.score ?? 0); + expect(scores[0]).toBeGreaterThan(0); + expect([...scores].sort((a, b) => b - a)).toEqual(scores); + }); +}); diff --git a/packages/core/src/query.test.ts b/packages/core/src/query.test.ts new file mode 100644 index 0000000..265ebd6 --- /dev/null +++ b/packages/core/src/query.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; + +import { blastRadius, 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: renderedText.map((text) => ({ text, source: "jsx" as const })), + 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"); + }); +}); + +describe("blastRadius", () => { + // The c1 shape: one shared DataTable, two pages, two APIs (mirrors the fixture). + const table = component("DataTable.tsx", "DataTable", ["No records found"]); + const usersPage = component("UsersPage.tsx", "UsersPage", ["All Users"]); + const invoicesPage = component("InvoicesPage.tsx", "InvoicesPage", ["All Invoices"]); + const usersTable = instance("UsersPage.tsx", 17, table); + const invoicesTable = instance("InvoicesPage.tsx", 17, table); + const usersApi = dataSource("UsersPage.tsx", "/api/users"); + const invoicesApi = dataSource("InvoicesPage.tsx", "/api/invoices"); + + const g = graph( + [table, usersPage, invoicesPage, usersTable, invoicesTable, usersApi, invoicesApi], + [ + { from: usersPage.id, to: usersTable.id, kind: "renders" }, + { from: usersTable.id, to: table.id, kind: "instance-of" }, + { from: invoicesPage.id, to: invoicesTable.id, kind: "renders" }, + { from: invoicesTable.id, to: table.id, kind: "instance-of" }, + { from: usersPage.id, to: usersApi.id, kind: "fetches-from" }, + { from: invoicesPage.id, to: invoicesApi.id, kind: "fetches-from" }, + { from: usersApi.id, to: usersTable.id, kind: "provides-data" }, + { from: invoicesApi.id, to: invoicesTable.id, kind: "provides-data" }, + ], + ); + + it("declines not-found for unknown targets", () => { + expect(blastRadius(g, "Nope").declineReason).toBe("not-found"); + }); + + it("finds both instances (d1) and both pages (d2) for a shared definition", () => { + const impacts = blastRadius(g, "DataTable").candidates[0]?.value ?? []; + const ids = impacts.map((i) => ({ id: i.node.id, distance: i.distance })); + expect(ids).toContainEqual({ id: usersTable.id, distance: 1 }); + expect(ids).toContainEqual({ id: invoicesTable.id, distance: 1 }); + expect(ids).toContainEqual({ id: usersPage.id, distance: 2 }); + expect(ids).toContainEqual({ id: invoicesPage.id, distance: 2 }); + }); + + it("resolves a data source by endpoint and lists only its consumers", () => { + const impacts = blastRadius(g, "/api/users").candidates[0]?.value ?? []; + const ids = impacts.map((i) => i.node.id); + expect(ids).toContain(usersPage.id); // fetcher + expect(ids).toContain(usersTable.id); // fed instance + // Over-reach guard: nothing from the invoices side leaks in. + expect(ids).not.toContain(invoicesPage.id); + expect(ids).not.toContain(invoicesApi.id); + expect(ids).not.toContain(invoicesTable.id); + }); + + it("honors the depth cap", () => { + const shallow = blastRadius(g, "DataTable", { depth: 1 }).candidates[0]?.value ?? []; + expect(shallow.every((i) => i.distance <= 1)).toBe(true); + expect(shallow.some((i) => i.node.id === usersPage.id)).toBe(false); + }); + + it("returns a deterministic, high-confidence result", () => { + const result = blastRadius(g, "DataTable"); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.confidence.level).toBe("high"); + }); +}); diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index d25120e..38c339e 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -1,63 +1,444 @@ /** * 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 { fuzzyTokenMatch, normalizeText, textMatches, tokenize } from "./text.js"; import type { ComponentNode, DataSourceNode, + EdgeCondition, + EdgeKind, EventNode, + Evidence, + InstanceNode, LineageEdge, LineageGraph, LineageNode, + RenderedText, + RouteNode, + SourceLocation, StateNode, + StructuralSignature, + StructureDescriptor, } from "./types.js"; 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[]; + /** + * Ancestor components that also cover the matched terms but are less specific + * than `component` (TRACKER step 4.3). Present when the match resolved to the + * deepest node in a Page > Section > Card nesting β€” the ancestors are context, + * not competing candidates. + */ + context?: ComponentNode[]; } /** - * Rank components by overlap between `terms` (words/phrases read off a - * screenshot) 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). + * + * 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[]): ComponentMatch[] { - const needles = terms.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 1); - const matches: ComponentMatch[] = []; +/** + * A recorded correction (Phase 4.6, failure mode G4): a human confirmed that a + * set of terms means a specific component. Fed back as first-class evidence so + * the next identical query resolves the same way. + */ +export interface Correction { + terms: string[]; + /** Component definition name the terms were confirmed to mean. */ + component: string; +} - for (const node of graph.nodes) { - if (node.kind !== "component") continue; - const haystack = node.renderedText.map((t) => t.toLowerCase()); - const matchedText: string[] = []; - for (const needle of needles) { - const hit = haystack.find((h) => h.includes(needle) || needle.includes(h)); - if (hit !== undefined) matchedText.push(hit); +/** A screenshot/ticket query: visible text, a structure descriptor, or both. */ +export interface MatchQuery { + terms?: string[]; + structure?: StructureDescriptor; + /** + * Per-term weight multipliers (Phase 4.4): terms a vision adapter found + * inside an annotation (a circle/arrow the user drew) are boosted, e.g. 3Γ—, + * so the emphasized element outranks incidental text. + */ + boosts?: Record; + /** + * Business-vocabulary glossary (Phase 4.6, failure mode E2), phrase β†’ + * component name/id β€” "invoice widget" β†’ BillingSummaryCard. An alias hit is + * high-weight evidence, so a term that appears nowhere in the code still + * resolves. + */ + aliases?: Record; + /** Recorded corrections (Phase 4.6) β€” the highest-weight signal of all. */ + corrections?: Correction[]; +} + +/** Weight of a full structural match relative to a rare matched term. */ +const STRUCTURE_WEIGHT = 3; +/** + * Multiplier when a matched term also names the component itself β€” its name, + * props, or file (6F.7). "calendar" matching CalendarPanel outranks the same + * text rendered incidentally elsewhere; global character rarity alone can't + * tell them apart. + */ +const IDENTIFIER_AFFINITY = 1.5; +/** A glossary alias hit outweighs any text/structure evidence (Phase 4.6). */ +const ALIAS_WEIGHT = 10; +/** A recorded human correction is the strongest signal of all. */ +const CORRECTION_WEIGHT = 25; + +/** Back-compat text-only entry point. */ +export function matchComponentsByText( + graph: LineageGraph, + terms: string[], +): QueryResult { + 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"); + + // 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) { - matches.push({ component: node, score: matchedText.length, matchedText }); + 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; + }; + + // Boost keys are normalized so they match the normalized query terms below. + const boosts = new Map( + Object.entries(query.boosts ?? {}).map(([term, weight]) => [normalizeText(term), weight]), + ); + const termWeight = (term: string): number => { + const base = tokenize(term).reduce((sum, t) => sum + idf(t), 0); + return base * (boosts.get(term) ?? 1); + }; + + // A component's own identifiers β€” name, props, file basename β€” split on + // camelCase and separators. Terms that also NAME the component score higher + // than the same text rendered incidentally elsewhere (6F.7). + const identifierMemo = new Map>(); + const identifierTokens = (component: ComponentNode): Set => { + const cached = identifierMemo.get(component.id); + if (cached) return cached; + const camelSplit = (s: string): string => s.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); + const basename = component.loc.file.split("/").pop()?.replace(/\.[a-z]+$/i, "") ?? ""; + const out = new Set( + tokenize( + [component.name, ...component.props, basename].map(camelSplit).join(" "), + ), + ); + identifierMemo.set(component.id, out); + return out; + }; + const namesComponent = (term: string, component: ComponentNode): boolean => { + const ids = identifierTokens(component); + return tokenize(term).some((t) => ids.has(t) || [...ids].some((id) => fuzzyTokenMatch(id, t))); + }; + + // Glossary aliases + recorded corrections (Phase 4.6). Both are authority + // signals β€” a phrase resolves even when it appears nowhere in the code. + const aliasEntries = Object.entries(query.aliases ?? {}); + const corrections = query.corrections ?? []; + const containsTerm = (needle: string): boolean => + needle.length > 0 && queryTerms.some((t) => t === needle || t.includes(needle) || needle.includes(t)); + + // Render tree (definition level): A renders B when A has a `renders` edge to + // an instance whose `instance-of` points at B. Used to collapse nested + // matches to the most specific one (step 4.3). + const byId = new Map(components.map((c) => [c.id, c])); + const instanceDef = new Map(); + for (const e of graph.edges) if (e.kind === "instance-of") instanceDef.set(e.from, e.to); + const childDefs = new Map>(); + for (const e of graph.edges) { + if (e.kind !== "renders") continue; + const childDef = instanceDef.get(e.to); + if (childDef === undefined || !byId.has(e.from) || !byId.has(childDef)) continue; + (childDefs.get(e.from) ?? childDefs.set(e.from, new Set()).get(e.from)!).add(childDef); + } + const descendantsMemo = new Map>(); + const descendants = (id: string): Set => { + const cached = descendantsMemo.get(id); + if (cached) return cached; + const out = new Set(); + descendantsMemo.set(id, out); // set first to break cycles + for (const child of childDefs.get(id) ?? []) { + if (out.has(child)) continue; + out.add(child); + for (const d of descendants(child)) out.add(d); + } + return out; + }; + + interface Scored { + match: ComponentMatch; + score: number; + coverage: number; + covered: Set; + evidence: Evidence[]; } + const scored: Scored[] = []; - return matches.sort((a, b) => b.score - a.score); + for (const component of components) { + const subtree = [component.id, ...descendants(component.id)]; + const matched: string[] = []; + const covered = new Set(); + const evidence: Evidence[] = []; + let weight = 0; + for (const term of queryTerms) { + let hit: RenderedText | null = null; + let where = component; + for (const id of subtree) { + const node = byId.get(id); + if (node === undefined) continue; + const found = matchingEntry(term, node); + if (found !== null) { + hit = found; + where = node; + break; + } + } + if (hit === null) continue; + const affine = namesComponent(term, component); + const w = termWeight(term) * (affine ? IDENTIFIER_AFFINITY : 1); + covered.add(term); + if (where.id === component.id) 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})` + : where.id !== component.id + ? ` (in descendant ${where.name})` + : ""; + evidence.push({ + kind: "text-match", + detail: + `"${term}" matched rendered text "${hit.text}"${provenance} β€” rarity weight ${w.toFixed(2)}` + + (affine ? " (also names the component)" : ""), + loc: where.loc, + }); + } + const textScore = covered.size > 0 ? weight * (1 + 0.5 * (covered.size - 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, + }); + } + + // Authority: glossary aliases and recorded corrections that name this + // component, when the query actually contains their phrase/terms. + let authority = 0; + const normName = normalizeText(component.name); + for (const [phrase, target] of aliasEntries) { + if (normalizeText(target) !== normName && target !== component.id) continue; + const normPhrase = normalizeText(phrase); + if (!containsTerm(normPhrase)) continue; + authority += ALIAS_WEIGHT; + covered.add(normPhrase); + if (!matched.includes(normPhrase)) matched.push(normPhrase); + evidence.push({ + kind: "alias", + detail: `glossary alias "${phrase}" β†’ ${component.name}`, + loc: component.loc, + }); + } + for (const correction of corrections) { + if (normalizeText(correction.component) !== normName) continue; + const cterms = correction.terms.map(normalizeText).filter((t) => t.length > 0); + if (cterms.length === 0 || !cterms.every(containsTerm)) continue; + authority += CORRECTION_WEIGHT; + for (const ct of cterms) covered.add(ct); + evidence.push({ + kind: "correction", + detail: `recorded correction [${correction.terms.join(", ")}] β†’ ${component.name}`, + loc: component.loc, + }); + } + + if (covered.size === 0 && structureFit === 0 && authority === 0) continue; + const coverage = + authority > 0 ? 1 : queryTerms.length > 0 ? covered.size / queryTerms.length : structureFit; + scored.push({ + match: { + component, + instances: instancesByDefinition.get(component.id) ?? [], + matchedText: matched.length > 0 ? matched : [...covered], + }, + score: textScore + structureFit * STRUCTURE_WEIGHT + authority, + coverage: Math.max(coverage, structureFit), + covered, + evidence, + }); + } + + if (scored.length === 0) return declined("no-signal"); + + const sameCovered = (a: Set, b: Set): boolean => + a.size === b.size && [...a].every((t) => b.has(t)); + + // Most-specific-subtree collapse: when an ancestor and a descendant cover the + // same term set (equal score), keep the deepest and fold ancestors into its + // `context`, so a Card that a Page renders wins with the Page as context. + const subtreeSize = (s: Scored): number => descendants(s.match.component.id).size; + scored.sort( + (a, b) => + b.score - a.score || + subtreeSize(a) - subtreeSize(b) || + a.match.component.name.localeCompare(b.match.component.name), + ); + const collapsed = new Set(); + for (const s of scored) { + if (collapsed.has(s.match.component.id)) continue; + const context: ComponentNode[] = []; + for (const other of scored) { + if (other === s || collapsed.has(other.match.component.id)) continue; + const otherOwnsS = descendants(other.match.component.id).has(s.match.component.id); + if (otherOwnsS && sameCovered(other.covered, s.covered)) { + context.push(other.match.component); + collapsed.add(other.match.component.id); + } + } + if (context.length > 0) s.match.context = context; + } + const winners = scored.filter((s) => !collapsed.has(s.match.component.id)); + + const candidates: Candidate[] = winners.map((s) => { + const conf = confidenceFromScore(s.coverage); + // Structure-only matches (no text, alias, or correction evidence β€” A3/A12) + // are an honest fallback, never "high": shape alone can't be certain. + const confidence = + s.covered.size === 0 && conf.level === "high" + ? { score: conf.score, level: "medium" as const } + : conf; + return { value: s.match, confidence, evidence: s.evidence, score: s.score }; + }); + + const top = winners[0]; + const tied = + top === undefined ? [] : winners.filter((s) => Math.abs(s.score - top.score) < 1e-9); + if (tied.length > 1) { + // A concrete question built from the DIFFERENCES between the leaders: each + // gets the first text unique to it, so the caller can answer with a term + // that resolves the tie (D6/G1). + const options = tied.slice(0, 3).map((s) => { + const elsewhere = new Set( + tied + .filter((o) => o !== s) + .flatMap((o) => o.match.component.renderedText.map((r) => normalizeText(r.text))), + ); + const distinctive = s.match.component.renderedText.find( + (r) => normalizeText(r.text).length > 0 && !elsewhere.has(normalizeText(r.text)), + ); + return distinctive !== undefined + ? `${s.match.component.name} (shows "${distinctive.text}")` + : s.match.component.name; + }); + return ambiguous( + candidates, + `Which one β€” ${options.join(", or ")}? Add a term unique to the one you mean.`, + ); + } + return ok(candidates); +} + +export interface InstanceAttribution { + instance: InstanceNode; + /** Data flowing INTO this call site through props (provides-data edges). */ + dataSources: DataSourceNode[]; } 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[]; + /** + * Definition-level traces only: what each call site receives, kept SEPARATE + * per instance β€” never merged, because merging is exactly the C1 poison + * (the users-page table would appear to consume the invoices API). + */ + perInstance?: InstanceAttribution[]; } /** - * 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 +447,69 @@ 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"); + + 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: start, dataSources: [], state: [], events: [], via: [] }; - const seen = new Set([componentId]); - const queue: string[] = [componentId]; + 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; + + // Data flowing INTO the traced instance through props (provides-data points + // data-source β†’ instance, so it is invisible to an outgoing-edge walk). + if (instance !== null) { + for (const edge of graph.edges) { + if (edge.kind !== "provides-data" || edge.to !== instance.id) continue; + edgesWalked += 1; + const source = byId.get(edge.from); + if (source !== undefined && source.kind === "data-source" && !seen.has(source.id)) { + seen.add(source.id); + lineage.dataSources.push(source); + } + } + } + + // Definition-level trace: report what each call site receives, per instance. + if (instance === null) { + const perInstance: InstanceAttribution[] = []; + for (const node of graph.nodes) { + if (node.kind !== "instance" || node.definitionId !== component.id) continue; + const incoming = graph.edges.flatMap((edge) => { + if (edge.kind !== "provides-data" || edge.to !== node.id) return []; + const source = byId.get(edge.from); + return source !== undefined && source.kind === "data-source" ? [source] : []; + }); + perInstance.push({ instance: node, dataSources: incoming }); + } + if (perInstance.length > 0) lineage.perInstance = perInstance; + } 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); @@ -87,6 +520,18 @@ export function traceLineage(graph: LineageGraph, componentId: string): Lineage break; case "state": lineage.state.push(node); + // Temporal decoupling (C6): the API that POPULATED this state may + // have been called by a different component on a different page. + // writes-state edges point data-source β†’ state; follow them back. + for (const writer of graph.edges) { + if (writer.kind !== "writes-state" || writer.to !== node.id) continue; + edgesWalked += 1; + const source = byId.get(writer.from); + if (source !== undefined && source.kind === "data-source" && !seen.has(source.id)) { + seen.add(source.id); + lineage.dataSources.push(source); + } + } break; case "event": lineage.events.push(node); @@ -94,6 +539,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 +547,407 @@ 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 }]); +} + +export type JourneyStepKind = "page" | "event" | "navigate" | "fetch" | "state-write" | "exit"; + +/** One node on a user-journey path, with the condition (if any) that gated it. */ +export interface JourneyStep { + kind: JourneyStepKind; + nodeId: string; + /** Route path (page/navigate), event name, endpoint (fetch), or state name. */ + label: string; + loc?: SourceLocation; + /** Flag/role/branch guarding the edge into this step (populated by step 3.5). */ + condition?: EdgeCondition; +} + +/** How a journey path ended: a leaf effect, a revisited page, or the depth cap. */ +export type JourneyEnd = "terminal" | "cycle" | "depth-limit"; + +export interface JourneyPath { + steps: JourneyStep[]; + end: JourneyEnd; +} + +export interface JourneyOptions { + /** Maximum number of page (navigation) levels a path may span. Default 3. */ + depth?: number; + /** Total path cap; when hit, the result is flagged truncated. Default 256. */ + maxPaths?: number; +} + +/** + * Enumerate the user-journey paths reachable from a page or component + * (TRACKER step 3.3, failure modes B5/B6). + * + * A journey alternates page β†’ event β†’ effect: on each screen the user can fire + * an event (a click, submit…), whose action effects (step 3.2) either navigate + * to another route β€” continuing the journey on the next page β€” or terminate the + * path at a fetch or a state write. Paths are expanded at query time; a per-path + * visited-set of pages means a node may recur across paths but never loops + * within one (the list ↔ detail cycle yields a finite path that ends "cycle"). + * + * `start` is a route path ("/users/:id"), an instance id, or a component + * name/id. Returns one candidate whose value is every path found. + */ +export function journeys( + graph: LineageGraph, + start: string, + options: JourneyOptions = {}, +): 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(); + 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) ?? []; + + // 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; + // 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, + ...(stepCondition ? { condition: stepCondition } : {}), + }; + 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 } : {}), + ...(stepCondition ? { condition: stepCondition } : {}), + }; + expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited); + } else if ( + effect.kind === "triggers" || + effect.kind === "writes-state" || + effect.kind === "exits-app" + ) { + 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 } + : target.kind === "external" + ? { kind: "exit", nodeId: target.id, label: target.host, 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 }]); +} + +/** + * 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; + 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) { + if (node.kind !== "instance") continue; + const list = byDefinition.get(node.definitionId); + if (list) list.push(node); + else byDefinition.set(node.definitionId, [node]); + } + return byDefinition; +} + +/** One node affected by changing the blast-radius target, with the hop that reaches it. */ +export interface ImpactNode { + node: LineageNode; + /** The edge kind connecting this node to its (closer) dependency on the path. */ + relation: EdgeKind; + /** Reverse-dependency hops from the target (1 = a direct dependent). */ + distance: number; +} + +export interface BlastRadiusOptions { + /** Maximum dependency hops to follow. Default Infinity (whole component). */ + depth?: number; +} + +/** + * For each edge, decide which endpoint is the *resource* and which *depends on* + * it, so we can walk "who is affected if I change X" regardless of edge + * direction. Journey edges (handles/triggers/navigates-to/exits-app/enters-at) + * are behaviour, not data/render dependencies, so they don't propagate impact. + */ +function dependencyOf(edge: LineageEdge): { resource: string; dependent: string } | null { + switch (edge.kind) { + // consumer --edge--> resource: the `from` depends on the `to`. + case "instance-of": // instance depends on its definition + case "renders": // a parent's render depends on the child instance + case "fetches-from": // a component/hook depends on the data source it calls + case "reads-state": // a reader depends on the state slice + case "uses-hook": // a component depends on the hook + case "routes-to": // a route depends on the page it renders + return { resource: edge.to, dependent: edge.from }; + // resource --edge--> consumer: the `to` depends on the `from`. + case "provides-data": // a fed instance depends on the data source + case "writes-state": // the written state depends on its writer + case "covered-by": // a test depends on the component it renders + return { resource: edge.from, dependent: edge.to }; + default: + return null; + } +} + +/** + * Blast radius (TRACKER step 5.3, failure mode F2): everything that *depends on* + * a node, so a change to it can be reviewed for impact. A reverse-dependency BFS + * β€” changing a component definition surfaces its instances and the pages that + * render them; changing a data source surfaces every consumer, the instances it + * feeds, and the state it writes (and their readers). + * + * `target` is a node id, a component name, a data-source endpoint, a state name, + * or a route path. Returns one candidate whose value is the affected nodes, + * ordered nearest-first. + */ +export function blastRadius( + graph: LineageGraph, + target: string, + options: BlastRadiusOptions = {}, +): QueryResult { + const depth = options.depth ?? Number.POSITIVE_INFINITY; + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const start = + byId.get(target) ?? + graph.nodes.find((n) => n.kind === "component" && n.name === target) ?? + graph.nodes.find((n) => n.kind === "data-source" && n.endpoint === target) ?? + graph.nodes.find((n) => n.kind === "state" && n.name === target) ?? + graph.nodes.find((n) => n.kind === "route" && n.path === target); + if (start === undefined) return declined("not-found"); + + // resource id -> its direct dependents (and the edge that makes them depend). + const dependents = new Map(); + for (const edge of graph.edges) { + const dep = dependencyOf(edge); + if (dep === null) continue; + const list = dependents.get(dep.resource); + if (list) list.push({ id: dep.dependent, relation: edge.kind }); + else dependents.set(dep.resource, [{ id: dep.dependent, relation: edge.kind }]); + } + + const seen = new Set([start.id]); + const queue: { id: string; distance: number }[] = [{ id: start.id, distance: 0 }]; + const impacts: ImpactNode[] = []; + while (queue.length > 0) { + const { id, distance } = queue.shift() as { id: string; distance: number }; + if (distance >= depth) continue; + for (const { id: dependentId, relation } of dependents.get(id) ?? []) { + if (seen.has(dependentId)) continue; + seen.add(dependentId); + const node = byId.get(dependentId); + if (node === undefined) continue; + impacts.push({ node, relation, distance: distance + 1 }); + queue.push({ id: dependentId, distance: distance + 1 }); + } + } + impacts.sort((a, b) => a.distance - b.distance); + + const evidence: Evidence[] = [ + { + kind: "edge-chain", + detail: `${impacts.length} node(s) depend on ${start.kind} ${labelOf(start)}`, + loc: start.loc, + }, + ]; + return ok([{ value: impacts, confidence: confidenceFromScore(1), evidence }]); +} + +function labelOf(node: LineageNode): string { + switch (node.kind) { + case "component": + case "hook": + case "state": + return node.name; + case "event": + return node.handler ?? node.event; + case "data-source": + return node.endpoint; + case "route": + return node.path; + default: + return node.id; + } } diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts new file mode 100644 index 0000000..f8ad898 --- /dev/null +++ b/packages/core/src/result.ts @@ -0,0 +1,72 @@ +/** + * 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[]; + /** + * Raw ranking score, exposed top-level so agents don't misread the nested + * calibration score as match strength (6F.7). Larger is stronger; only + * comparable within one result. `confidence` stays the calibrated signal. + */ + score?: number; +} + +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 }; +} + +/** + * Score β†’ confidence thresholds, calibrated on the eval set (TRACKER step 4.5): + * every `high` answer in the eval set is correct at these cutoffs. The eval + * `--calibrate` subcommand measures precision at these levels and records it in + * eval/calibration.json. + */ +export const CONFIDENCE_THRESHOLDS = { high: 0.8, medium: 0.5 } as const; + +/** Map a 0–1 score to a calibrated confidence level. */ +export function confidenceFromScore(score: number): Confidence { + const clamped = Math.max(0, Math.min(1, score)); + return { + score: clamped, + level: + clamped >= CONFIDENCE_THRESHOLDS.high + ? "high" + : clamped >= CONFIDENCE_THRESHOLDS.medium + ? "medium" + : "low", + }; +} 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/packages/core/src/text.test.ts b/packages/core/src/text.test.ts new file mode 100644 index 0000000..34eb160 --- /dev/null +++ b/packages/core/src/text.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { hasMatchSignal, 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); + }); + + it("never matches targets that normalize to empty or near-empty (A14)", () => { + // "|" / "/" / "-" normalize to "" β€” an empty haystack is a substring of + // every query, which turned the matcher into a universal wildcard. + expect(normalizeText("|")).toBe(""); + expect(textMatches(normalizeText("|"), "zzqwxnomatch12345")).toBe(false); + expect(textMatches("", "calendar")).toBe(false); + expect(textMatches("*", "anything")).toBe(false); // pure wildcard β†’ /.*/ + expect(textMatches("x", "x ray")).toBe(false); // single char: below signal + }); +}); + +describe("hasMatchSignal", () => { + it("rejects empty, punctuation-only, and pure-wildcard targets", () => { + expect(hasMatchSignal("")).toBe(false); + expect(hasMatchSignal(normalizeText("| / -"))).toBe(false); + expect(hasMatchSignal("*")).toBe(false); + expect(hasMatchSignal("* *")).toBe(false); + expect(hasMatchSignal("x")).toBe(false); + }); + + it("accepts real text, including wildcard templates", () => { + expect(hasMatchSignal("save")).toBe(true); + expect(hasMatchSignal("* item in cart")).toBe(true); + expect(hasMatchSignal("ok")).toBe(true); + }); +}); diff --git a/packages/core/src/text.ts b/packages/core/src/text.ts new file mode 100644 index 0000000..66591bd --- /dev/null +++ b/packages/core/src/text.ts @@ -0,0 +1,114 @@ +/** + * 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(" "); +} + +/** Minimum alphanumeric characters a match target must carry (failure mode A14). */ +const MIN_TARGET_SIGNAL = 2; + +/** + * True when a normalized string carries enough alphanumeric signal to act as a + * match target. Punctuation-only rendered text ("|", "/", "Β·") normalizes to + * "" β€” and an empty haystack is a substring of every query, so without this + * guard such targets match anything and the matcher degenerates into a + * universal wildcard (failure mode A14, field-found in v0.3.0). Pure-wildcard + * templates ("*" from a fully-dynamic expression) collapse to a match-everything + * regex the same way. + */ +export function hasMatchSignal(normalized: string): boolean { + let signal = 0; + for (const ch of normalized) { + if (ch === " " || ch === "*") continue; + signal += 1; + if (signal >= MIN_TARGET_SIGNAL) return true; + } + return false; +} + +/** + * Does `needle` (normalized) match `haystack` (normalized), where `haystack` + * may contain `*` wildcards from template text ("* item in cart" matches + * "3 items in cart")? Targets below the minimum alphanumeric signal never + * match (A14). + */ +export function textMatches(haystack: string, needle: string): boolean { + if (!hasMatchSignal(haystack)) return false; + 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; +} + +/** 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; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9278c75..2b24e43 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,77 @@ export interface SourceLocation { endLine: number; } -export type NodeKind = "component" | "hook" | "data-source" | "state" | "event"; +export type NodeKind = + | "component" + | "hook" + | "instance" + | "data-source" + | "state" + | "event" + | "route" + | "external" + | "test"; 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[]; +} + +/** 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), an i18n key resolved against + * locale files, or portal-rendered content (toast("Order deleted") β€” the + * text appears far from the caller in the DOM, but the CALLER is the + * component a screenshot of it should match). + */ + source: "jsx" | "attribute" | "i18n" | "portal"; + /** 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. */ + 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 β€” the thing a screenshot shows. */ +/** + * 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; + /** `` (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": "`
    `/`
      `/`
    1. ` groupings." + }, + "repeated": { + "type": "number", + "description": "`.map(...)` JSX renders β€” repeated cards/rows/grid items." + } + }, + "required": [ + "table", + "columns", + "form", + "input", + "button", + "link", + "image", + "heading", + "list", + "repeated" + ], + "additionalProperties": false, + "description": "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." + }, + "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": "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" + }, + "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." + }, + "responseType": { + "$ref": "#/definitions/ResponseType", + "description": "The response shape this source returns, when recoverable (5.5, F4)." + } + }, + "required": [ + "endpoint", + "id", + "kind", + "loc", + "method", + "name", + "raw", + "resolved", + "sourceKind" + ], + "additionalProperties": false, + "description": "An external data origin: an HTTP endpoint, GraphQL operation, or socket." + }, + "DataSourceKind": { + "type": "string", + "enum": [ + "fetch", + "axios", + "react-query", + "rtk-query", + "swr", + "graphql", + "websocket", + "unknown" + ] + }, + "EndpointResolution": { + "type": "string", + "enum": [ + "full", + "partial", + "none" + ], + "description": "How much of an endpoint was statically resolvable." + }, + "ResponseType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Named type when resolvable (\"User\", \"User[]\"); otherwise the type text." + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/ResponseField" + } + }, + "source": { + "type": "string", + "enum": [ + "generic", + "annotation", + "openapi" + ], + "description": "Where the type was recovered from." + } + }, + "required": [ + "name", + "fields", + "source" + ], + "additionalProperties": false, + "description": "The shape a data source returns (TRACKER step 5.4β†’5.5, failure mode F4). Resolved from a call's generic argument (`useQuery`), a type annotation on the variable the result lands in, or an OpenAPI spec matched by endpoint. One level deep only β€” fields, not their nested shapes." + }, + "ResponseField": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Field type text, e.g. \"string\", \"number | null\", \"Address\"." + } + }, + "required": [ + "name", + "type" + ], + "additionalProperties": false, + "description": "One field of a response type β€” a name and its type as written." + }, + "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", + "class-state", + "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\", or a DOM/hotkey key (\"keydown\", \"ctrl+s\")." + }, + "handler": { + "type": [ + "string", + "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": [ + "event", + "handler", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "A user or system event a component responds to." + }, + "RouteNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "route" + }, + "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." + }, + "path": { + "type": "string", + "description": "Route pattern with dynamic segments in :param form β€” \"/users/:id\", catch-alls as \":param*\". Matches the endpoint-pattern convention so navigate(\"/users/\" + id) effects (step 3.2) join on the same shape." + }, + "router": { + "$ref": "#/definitions/RouterKind" + }, + "layout": { + "type": [ + "string", + "null" + ], + "description": "Name of the layout component wrapping this route's page (a pathless layout route in React Router; the nearest layout.tsx in Next.js app router). Null when the page renders bare." + }, + "guards": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Guard components between the route and its page β€” auth/role wrappers like around the element or as pathless ancestor routes." + } + }, + "required": [ + "guards", + "id", + "kind", + "layout", + "loc", + "name", + "path", + "router" + ], + "additionalProperties": false, + "description": "A URL route and the page component it renders (TRACKER step 3.1, failure mode B4). Routes are journey-graph entry points: a journey step like \"navigate to /users/:id\" resolves through the route to the page component's instance tree." + }, + "RouterKind": { + "type": "string", + "enum": [ + "react-router", + "nextjs-app", + "nextjs-pages" + ], + "description": "Which routing system declared a route." + }, + "ExternalNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "external" + }, + "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." + }, + "url": { + "type": "string", + "description": "Destination as written β€” a URL, a `mailto:`/`tel:` scheme, or \"inbound\"." + }, + "host": { + "type": "string", + "description": "Host or scheme used to group destinations (accounts.google.com, mailto)." + } + }, + "required": [ + "host", + "id", + "kind", + "loc", + "name", + "url" + ], + "additionalProperties": false, + "description": "A destination outside this codebase (TRACKER failure mode B9): an OAuth provider, a payment gateway, a `mailto:`/`tel:` link, or the inbound side of a deep-link/OAuth-callback route. Journeys that reach one leave the app." + }, + "TestNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "test" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "framework": { + "type": "string", + "enum": [ + "vitest", + "jest", + "unknown" + ], + "description": "Which runner the file appears to use, by its imports/globals." + } + }, + "required": [ + "framework", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "A test file that exercises one or more components (TRACKER step 5.4, failure mode F3). Linked to the components it renders by `covered-by` edges, so the context bundle can name the tests that guard a change β€” and flag components that have none." + }, + "LineageEdge": { + "type": "object", + "properties": { + "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", + "navigates-to", + "exits-app", + "enters-at", + "covered-by", + "routes-to" + ] + }, + "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)." + } + } +}