diff --git a/TRACKER.md b/TRACKER.md index 4c63f6b..50eb365 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 5 — Context bundle & agent interface -- **Next step:** 5.2 — Context-bundle contract -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1 +- **Next step:** 5.3 — Blast radius (reverse traversal) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.2 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) ## What CodeRadar is @@ -274,7 +274,7 @@ The heart of the project. C1 and B1 live here. - 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. diff --git a/packages/agent-sdk/package.json b/packages/agent-sdk/package.json index af8d1d1..fc59c54 100644 --- a/packages/agent-sdk/package.json +++ b/packages/agent-sdk/package.json @@ -19,13 +19,15 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" + "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/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..6c6fad3 --- /dev/null +++ b/packages/agent-sdk/src/bundle.ts @@ -0,0 +1,183 @@ +/** + * 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 { + type JourneyPath, + journeys, + type LineageGraph, + traceLineage, +} from "@coderadar/core"; + +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 BundleLineageEntry { + /** Component name, or `Name@file:line` for a specific instance. */ + target: string; + dataSources: { method: string | null; endpoint: string }[]; + 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) — empty until then. */ +export interface BundleCommit { + sha: string; + subject: string; +} + +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; +} + +/** 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 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((d) => ({ + method: d.method, + endpoint: d.endpoint, + })), + 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((d) => ({ method: d.method, endpoint: d.endpoint })), + state: [], + events: [], + }); + } + bundle.journeys = journeys(graph, top.component.name, { depth }).candidates[0]?.value ?? []; + } + + 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); +} + +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/index.ts b/packages/agent-sdk/src/index.ts index 7cac608..e371761 100644 --- a/packages/agent-sdk/src/index.ts +++ b/packages/agent-sdk/src/index.ts @@ -1,3 +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/tsconfig.json b/packages/agent-sdk/tsconfig.json index 5285d28..90d6d34 100644 --- a/packages/agent-sdk/tsconfig.json +++ b/packages/agent-sdk/tsconfig.json @@ -4,5 +4,6 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2529e86..d72bc86 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,7 +16,7 @@ import { saveGraph, traceLineage, } from "@coderadar/core"; -import { resolveContext } from "@coderadar/agent-sdk"; +import { buildBundle, resolveContext } from "@coderadar/agent-sdk"; import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; import { Command } from "commander"; import { parse as parseYaml } from "yaml"; @@ -205,6 +205,24 @@ program 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("correct") .description("Record that some on-screen text means a component — feeds future `find` results") diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts index 858d631..8bdb480 100644 --- a/packages/cli/src/lib.ts +++ b/packages/cli/src/lib.ts @@ -10,7 +10,9 @@ export * from "@coderadar/core"; export { resolveHookEdges, scanReact, type ScanOptions } from "@coderadar/parser-react"; export { + buildBundle, classifyTicket, + type ContextBundle, type ContextResult, type EntryPoint, resolveContext, diff --git a/packages/parser-react/package.json b/packages/parser-react/package.json index 5dddf6b..31076a5 100644 --- a/packages/parser-react/package.json +++ b/packages/parser-react/package.json @@ -27,6 +27,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@coderadar/agent-sdk": "workspace:*", "@coderadar/vision": "workspace:*", "@types/node": "^22.20.1", "typescript": "^5.7.0", diff --git a/packages/parser-react/src/bundle.test.ts b/packages/parser-react/src/bundle.test.ts new file mode 100644 index 0000000..3f02681 --- /dev/null +++ b/packages/parser-react/src/bundle.test.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildBundle, estimateTokens } from "@coderadar/agent-sdk"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +// A real scanned graph with routes, effects, and journeys (the b3 fixture). +const graph = resolveHookEdges( + scanReact({ + root: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/b3-programmatic-nav/app", + ), + }), +); + +describe("context bundle over a real scanned graph (TRACKER 5.2)", () => { + it("builds a bundle with match, lineage, and journeys for a UI ticket", () => { + const bundle = buildBundle(graph, { text: "the 'All users' page is broken" }, { budgetTokens: 8000 }); + expect(bundle.status).toBe("matched"); + expect(bundle.match[0]?.component).toBe("UsersPage"); + expect(bundle.journeys.length).toBeGreaterThan(0); + }); + + it("every bundle fits the token budget at 2k / 4k / 8k", () => { + const tickets = [ + "the 'All users' page is broken", + "Clicking Refresh does nothing on the users list", + "the 'Your cart' screen shows the wrong total", + ]; + for (const text of tickets) { + for (const budget of [2000, 4000, 8000]) { + const bundle = buildBundle(graph, { text }, { budgetTokens: budget }); + expect(estimateTokens(bundle)).toBeLessThanOrEqual(budget); + } + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac15674..ee27026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@types/node': specifier: ^22.20.1 version: 22.20.1 + ts-json-schema-generator: + specifier: ^2.9.0 + version: 2.9.0 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -107,6 +110,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@coderadar/agent-sdk': + specifier: workspace:* + version: link:../agent-sdk '@coderadar/vision': specifier: workspace:* version: link:../vision diff --git a/schemas/context-bundle.schema.json b/schemas/context-bundle.schema.json new file mode 100644 index 0000000..7baeec8 --- /dev/null +++ b/schemas/context-bundle.schema.json @@ -0,0 +1,369 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/ContextBundle", + "definitions": { + "ContextBundle": { + "type": "object", + "properties": { + "ticket": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "entryPoint": { + "$ref": "#/definitions/EntryPoint" + } + }, + "required": [ + "text", + "entryPoint" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "matched", + "ambiguous", + "declined" + ] + }, + "match": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleMatch" + } + }, + "lineage": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleLineageEntry" + } + }, + "journeys": { + "type": "array", + "items": { + "$ref": "#/definitions/JourneyPath" + } + }, + "blastRadius": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleImpact" + } + }, + "tests": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleTest" + } + }, + "history": { + "type": "array", + "items": { + "$ref": "#/definitions/BundleCommit" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "budget": { + "type": "object", + "properties": { + "tokens": { + "type": "number" + }, + "used": { + "type": "number" + } + }, + "required": [ + "tokens", + "used" + ], + "additionalProperties": false + } + }, + "required": [ + "ticket", + "status", + "match", + "lineage", + "journeys", + "blastRadius", + "tests", + "history", + "warnings", + "budget" + ], + "additionalProperties": false + }, + "EntryPoint": { + "type": "string", + "enum": [ + "visual", + "textual", + "behavioral", + "out-of-domain", + "unsupported" + ], + "description": "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)." + }, + "BundleMatch": { + "type": "object", + "properties": { + "component": { + "type": "string" + }, + "instances": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ] + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "component", + "instances", + "confidence", + "evidence" + ], + "additionalProperties": false + }, + "BundleLineageEntry": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Component name, or `Name@file:line` for a specific instance." + }, + "dataSources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": [ + "string", + "null" + ] + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "method", + "endpoint" + ], + "additionalProperties": false + } + }, + "state": { + "type": "array", + "items": { + "type": "string" + } + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "target", + "dataSources", + "state", + "events" + ], + "additionalProperties": false + }, + "JourneyPath": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/JourneyStep" + } + }, + "end": { + "$ref": "#/definitions/JourneyEnd" + } + }, + "required": [ + "steps", + "end" + ], + "additionalProperties": false + }, + "JourneyStep": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/JourneyStepKind" + }, + "nodeId": { + "type": "string" + }, + "label": { + "type": "string", + "description": "Route path (page/navigate), event name, endpoint (fetch), or state name." + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "condition": { + "$ref": "#/definitions/EdgeCondition", + "description": "Flag/role/branch guarding the edge into this step (populated by step 3.5)." + } + }, + "required": [ + "kind", + "nodeId", + "label" + ], + "additionalProperties": false, + "description": "One node on a user-journey path, with the condition (if any) that gated it." + }, + "JourneyStepKind": { + "type": "string", + "enum": [ + "page", + "event", + "navigate", + "fetch", + "state-write", + "exit" + ] + }, + "SourceLocation": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Path relative to the scan root, POSIX separators." + }, + "line": { + "type": "number", + "description": "1-based line of the declaration." + }, + "endLine": { + "type": "number", + "description": "1-based end line of the declaration." + } + }, + "required": [ + "file", + "line", + "endLine" + ], + "additionalProperties": false, + "description": "Where a node lives in the codebase." + }, + "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)." + }, + "JourneyEnd": { + "type": "string", + "enum": [ + "terminal", + "cycle", + "depth-limit" + ], + "description": "How a journey path ended: a leaf effect, a revisited page, or the depth cap." + }, + "BundleImpact": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "distance": { + "type": "number" + } + }, + "required": [ + "node", + "relation", + "distance" + ], + "additionalProperties": false, + "description": "Reverse-traversal impact (step 5.3) — empty until then." + }, + "BundleTest": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": [ + "file" + ], + "additionalProperties": false, + "description": "Test coverage (step 5.4) — empty until then." + }, + "BundleCommit": { + "type": "object", + "properties": { + "sha": { + "type": "string" + }, + "subject": { + "type": "string" + } + }, + "required": [ + "sha", + "subject" + ], + "additionalProperties": false, + "description": "Recent git history (step 5.6) — empty until then." + } + } +}