diff --git a/TRACKER.md b/TRACKER.md index aac2ecb..74f1c6c 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 0 — Foundations -- **Next step:** 0.3 — Graph storage & versioning -- **Done:** 0.1, 0.2 +- **Next step:** 0.4 — CI pipeline +- **Done:** 0.1, 0.2, 0.3 - **Gates passed:** none yet (Gate 0 completes with 0.4) ## What CodeRadar is @@ -67,12 +67,12 @@ The v0.1 schema is definition-only, which is *wrong* for C1 — fix before build - Fixtures: `c1-shared-datatable` (DataTable rendered by Users + Invoices pages, different APIs — must attribute per instance, `forbidden` catches definition-level attribution), `a4-generic-text` (three components sharing "Save"), plus `demo-app` moved under fixtures. **Accept:** `pnpm eval` runs green locally on the non-C1 assertions; C1 attribution assertions may be *red* (prop-flow lands in 2.2) but the fixture and its golden file exist and the runner reports them as `expected-fail: phase-2`. Expected-fail support is part of the runner. -### [ ] 0.3 Graph storage & versioning +### [x] 0.3 Graph storage & versioning **Failure modes:** G2, G3 (foundation), D1 (foundation) **Build:** - `GraphMeta` — `{ commitSha, dirty: boolean, generatedAt, generator, scanRoot }` embedded in `LineageGraph`. - `saveGraph(graph, path)` / `loadGraph(path)` in core with schema-version check (refuse to load a newer major version). -- JSON Schema for the whole graph exported to `dist/schemas/lineage-graph.schema.json` (generated from the TS types; committed; drift-gated by a test that regenerates and diffs). +- JSON Schema for the whole graph exported to `schemas/lineage-graph.schema.json` (repo root — `dist/` is gitignored; generated from the TS types; committed; drift-gated by a test that regenerates and diffs). - CLI: `scan` records commit SHA (via `git rev-parse`, `dirty` from `git status`). **Accept:** round-trip test (scan → save → load → deep-equal); schema drift test; `coderadar scan` output includes SHA. diff --git a/package.json b/package.json index 3f4f410..a120629 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "pnpm -r test", "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", - "eval": "pnpm -r build && node eval/dist/run.js" + "eval": "pnpm -r build && node eval/dist/run.js", + "schemas": "pnpm --filter @coderadar/core schema" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9421f87..563bfc9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,9 +4,12 @@ import path from "node:path"; import { type Candidate, + collectGraphMeta, type ComponentMatch, type LineageGraph, + loadGraph as loadGraphFile, matchComponentsByText, + saveGraph, traceLineage, } from "@coderadar/core"; import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; @@ -27,13 +30,17 @@ program .argument("", "directory to scan") .option("-o, --out ", "output file", "coderadar.graph.json") .action((dir: string, opts: { out: string }) => { - const graph = resolveHookEdges(scanReact({ root: dir })); - fs.writeFileSync(opts.out, JSON.stringify(graph, null, 2)); + const meta = collectGraphMeta(path.resolve(dir)); + const graph = { ...resolveHookEdges(scanReact({ root: dir })), meta }; + saveGraph(graph, opts.out); const counts = new Map(); for (const node of graph.nodes) { counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1); } console.log(`Scanned ${path.resolve(dir)}`); + console.log( + ` commit: ${meta.commitSha ?? "not a git repo"}${meta.dirty ? " (dirty working tree)" : ""}`, + ); for (const [kind, count] of [...counts].sort()) console.log(` ${kind}: ${count}`); console.log(` edges: ${graph.edges.length}`); console.log(`Graph written to ${opts.out}`); @@ -127,7 +134,12 @@ function loadGraph(file: string): LineageGraph { console.error(`Graph file not found: ${file} — run \`coderadar scan \` first.`); process.exit(1); } - return JSON.parse(fs.readFileSync(file, "utf-8")) as LineageGraph; + try { + return loadGraphFile(file); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } program.parse(); diff --git a/packages/core/package.json b/packages/core/package.json index e479749..da0344a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,9 +18,12 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" + "test": "vitest run", + "schema": "node scripts/gen-schema.mjs" }, "devDependencies": { + "@types/node": "^22.20.1", + "ts-json-schema-generator": "^2.9.0", "typescript": "^5.7.0", "vitest": "^3.2.7" } diff --git a/packages/core/scripts/gen-schema.mjs b/packages/core/scripts/gen-schema.mjs new file mode 100644 index 0000000..c426076 --- /dev/null +++ b/packages/core/scripts/gen-schema.mjs @@ -0,0 +1,12 @@ +/** Regenerate schemas/lineage-graph.schema.json from the TS types. */ +import fs from "node:fs"; +import path from "node:path"; + +import { createGenerator } from "ts-json-schema-generator"; + +import { generatorConfig, schemaOutPath } from "./schema-config.mjs"; + +const schema = createGenerator(generatorConfig).createSchema(generatorConfig.type); +fs.mkdirSync(path.dirname(schemaOutPath), { recursive: true }); +fs.writeFileSync(schemaOutPath, JSON.stringify(schema, null, 2) + "\n"); +console.log(`wrote ${schemaOutPath}`); diff --git a/packages/core/scripts/schema-config.mjs b/packages/core/scripts/schema-config.mjs new file mode 100644 index 0000000..a41e4e4 --- /dev/null +++ b/packages/core/scripts/schema-config.mjs @@ -0,0 +1,20 @@ +/** + * Shared config for JSON Schema generation — used by gen-schema.mjs (writes + * the committed file) and the drift-gate test (regenerates and diffs). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** ts-json-schema-generator config for the LineageGraph root type. */ +export const generatorConfig = { + path: path.join(packageDir, "src/types.ts"), + tsconfig: path.join(packageDir, "tsconfig.json"), + type: "LineageGraph", + topRef: true, + additionalProperties: false, +}; + +/** Committed schema location (repo root — dist/ is gitignored). */ +export const schemaOutPath = path.resolve(packageDir, "../../schemas/lineage-graph.schema.json"); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4362be2..55f6be9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./result.js"; export * from "./query.js"; +export * from "./storage.js"; diff --git a/packages/core/src/storage.test.ts b/packages/core/src/storage.test.ts new file mode 100644 index 0000000..8e8f3b2 --- /dev/null +++ b/packages/core/src/storage.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createGenerator } from "ts-json-schema-generator"; +import { describe, expect, it } from "vitest"; + +// eslint-disable-next-line import/no-relative-packages +import { generatorConfig, schemaOutPath } from "../scripts/schema-config.mjs"; +import { collectGraphMeta, loadGraph, saveGraph, SCHEMA_VERSION } from "./storage.js"; +import type { LineageGraph } from "./types.js"; + +const tmp = (name: string) => path.join(fs.mkdtempSync(path.join(os.tmpdir(), "coderadar-")), name); + +const graph: LineageGraph = { + version: 2, + root: "/scanned/app", + generatedAt: "2026-01-01T00:00:00Z", + generator: "test", + meta: { commitSha: "abc123", dirty: false }, + nodes: [], + edges: [], +}; + +describe("saveGraph / loadGraph", () => { + it("round-trips deep-equal", () => { + const file = tmp("graph.json"); + saveGraph(graph, file); + expect(loadGraph(file)).toEqual(graph); + }); + + it("refuses a graph newer than this library", () => { + const file = tmp("future.json"); + fs.writeFileSync(file, JSON.stringify({ ...graph, version: SCHEMA_VERSION + 1 })); + expect(() => loadGraph(file)).toThrow(/upgrade @coderadar\/core/); + }); + + it("refuses files that are not lineage graphs", () => { + const file = tmp("junk.json"); + fs.writeFileSync(file, JSON.stringify({ hello: "world" })); + expect(() => loadGraph(file)).toThrow(/no version field/); + + const badVersion = tmp("badversion.json"); + fs.writeFileSync(badVersion, JSON.stringify({ version: "two" })); + expect(() => loadGraph(badVersion)).toThrow(/version must be a number/); + }); +}); + +describe("collectGraphMeta", () => { + it("returns a SHA inside a git repo", () => { + const meta = collectGraphMeta(path.dirname(schemaOutPath)); + expect(meta.commitSha).toMatch(/^[0-9a-f]{40}$/); + }); + + it("returns null SHA outside a git repo", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coderadar-nogit-")); + expect(collectGraphMeta(dir).commitSha).toBeNull(); + }); +}); + +describe("JSON Schema drift gate", () => { + it("committed schema matches the current TS types — run `pnpm --filter @coderadar/core schema` after schema changes", () => { + const generated = createGenerator(generatorConfig).createSchema(generatorConfig.type); + const committed: unknown = JSON.parse(fs.readFileSync(schemaOutPath, "utf-8")); + expect(committed).toEqual(JSON.parse(JSON.stringify(generated))); + }); +}); diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts new file mode 100644 index 0000000..51a86f2 --- /dev/null +++ b/packages/core/src/storage.ts @@ -0,0 +1,63 @@ +/** + * Graph persistence with schema-version checking and scan provenance. + * + * A stored graph is the contract between the scan side (CI job, CLI) and the + * query side (agent SDK, MCP server) — possibly running months apart on + * different machines. Version checks refuse graphs newer than this library; + * GraphMeta records exactly which code was scanned (G2/G3 foundations). + */ + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; + +import type { GraphMeta, LineageGraph } from "./types.js"; + +/** The schema version this library reads and writes. */ +export const SCHEMA_VERSION = 2; + +export function saveGraph(graph: LineageGraph, filePath: string): void { + fs.writeFileSync(filePath, JSON.stringify(graph, null, 2)); +} + +/** + * Load and validate a stored graph. + * + * Throws on: unreadable/invalid JSON, missing version, or a version newer + * than this library (an older reader must not misinterpret a newer graph — + * upgrade the library instead). Older versions are accepted; migrations are + * added here when version 3 exists. + */ +export function loadGraph(filePath: string): LineageGraph { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { + throw new Error(`${filePath} is not a CodeRadar lineage graph (no version field)`); + } + const version = (parsed as { version: unknown }).version; + if (typeof version !== "number") { + throw new Error(`${filePath}: version must be a number, got ${typeof version}`); + } + if (version > SCHEMA_VERSION) { + throw new Error( + `${filePath} uses schema v${version}, but this library reads up to v${SCHEMA_VERSION} — upgrade @coderadar/core`, + ); + } + return parsed as LineageGraph; +} + +/** Collect scan provenance for a directory: commit SHA + dirty flag. */ +export function collectGraphMeta(scanRoot: string): GraphMeta { + try { + const commitSha = execFileSync("git", ["-C", scanRoot, "rev-parse", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const status = execFileSync("git", ["-C", scanRoot, "status", "--porcelain"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + return { commitSha, dirty: status.trim().length > 0 }; + } catch { + return { commitSha: null, dirty: false }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b783df1..cce41e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: packages/core: devDependencies: + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + ts-json-schema-generator: + specifier: ^2.9.0 + version: 2.9.0 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -382,6 +388,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -421,9 +430,17 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -443,6 +460,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -485,19 +506,40 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -506,9 +548,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -532,6 +582,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -570,9 +624,17 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + ts-json-schema-generator@2.9.0: + resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} + engines: {node: '>=22.0.0'} + hasBin: true + ts-morph@24.0.0: resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -831,6 +893,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -881,10 +945,16 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + cac@6.7.14: {} chai@5.3.3: @@ -901,6 +971,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -951,24 +1023,47 @@ snapshots: fsevents@2.3.3: optional: true + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + js-tokens@9.0.1: {} + json5@2.2.3: {} + loupe@3.2.1: {} + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.2 + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.15: {} + normalize-path@3.0.0: {} + path-browserify@1.0.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1014,6 +1109,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + safe-stable-stringify@2.5.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1041,11 +1138,24 @@ snapshots: tinyspy@4.0.4: {} + ts-json-schema-generator@2.9.0: + dependencies: + '@types/json-schema': 7.0.15 + commander: 14.0.3 + glob: 13.0.6 + json5: 2.2.3 + normalize-path: 3.0.0 + safe-stable-stringify: 2.5.0 + tslib: 2.8.1 + typescript: 5.9.3 + ts-morph@24.0.0: dependencies: '@ts-morph/common': 0.25.0 code-block-writer: 13.0.3 + tslib@2.8.1: {} + typescript@5.9.3: {} undici-types@6.21.0: {} diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json new file mode 100644 index 0000000..c3ba0c5 --- /dev/null +++ b/schemas/lineage-graph.schema.json @@ -0,0 +1,504 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/LineageGraph", + "definitions": { + "LineageGraph": { + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 2, + "description": "Schema version for forward compatibility." + }, + "root": { + "type": "string", + "description": "Absolute path of the scanned root at generation time." + }, + "generatedAt": { + "type": "string" + }, + "generator": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/GraphMeta" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/definitions/LineageNode" + } + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/definitions/LineageEdge" + } + } + }, + "required": [ + "version", + "root", + "generatedAt", + "generator", + "nodes", + "edges" + ], + "additionalProperties": false + }, + "GraphMeta": { + "type": "object", + "properties": { + "commitSha": { + "type": [ + "string", + "null" + ], + "description": "Commit SHA of the scanned tree; null when not a git repo." + }, + "dirty": { + "type": "boolean", + "description": "True when the working tree had uncommitted changes at scan time." + } + }, + "required": [ + "commitSha", + "dirty" + ], + "additionalProperties": false, + "description": "Scan provenance — which code this graph describes." + }, + "LineageNode": { + "anyOf": [ + { + "$ref": "#/definitions/ComponentNode" + }, + { + "$ref": "#/definitions/InstanceNode" + }, + { + "$ref": "#/definitions/HookNode" + }, + { + "$ref": "#/definitions/DataSourceNode" + }, + { + "$ref": "#/definitions/StateNode" + }, + { + "$ref": "#/definitions/EventNode" + } + ] + }, + "ComponentNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "component" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "exportName": { + "type": [ + "string", + "null" + ], + "description": "Named or default export, if exported." + }, + "props": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Prop names destructured or accessed from the props object." + }, + "renderedText": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Static text visible in the rendered output (JSX text, string literals in attributes like placeholder/label/title/alt/aria-label). This is the primary signal for matching a screenshot to a component." + }, + "rendersComponents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of components this component renders in its JSX (deduplicated)." + } + }, + "required": [ + "exportName", + "id", + "kind", + "loc", + "name", + "props", + "renderedText", + "rendersComponents" + ], + "additionalProperties": false, + "description": "A React component definition — the code, not a usage." + }, + "SourceLocation": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Path relative to the scan root, POSIX separators." + }, + "line": { + "type": "number", + "description": "1-based line of the declaration." + }, + "endLine": { + "type": "number", + "description": "1-based end line of the declaration." + } + }, + "required": [ + "file", + "line", + "endLine" + ], + "additionalProperties": false, + "description": "Where a node lives in the codebase." + }, + "InstanceNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "instance" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "definitionId": { + "type": "string", + "description": "Id of the ComponentNode this instantiates." + }, + "parentInstanceId": { + "type": [ + "string", + "null" + ], + "description": "Enclosing instance in the render tree. Null until the cross-file instance tree is built (Phase 2.1); the enclosing *definition* is available via the incoming `renders` edge meanwhile." + }, + "staticProps": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Props with statically-known string values at this call site." + } + }, + "required": [ + "definitionId", + "id", + "kind", + "loc", + "name", + "parentInstanceId", + "staticProps" + ], + "additionalProperties": false, + "description": "A component as rendered at one specific call site.\n\nThe same definition rendered on the Users page and the Invoices page yields two instances — and per-instance data attribution (Phase 2.2) is what lets each report a different API." + }, + "HookNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "hook" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "exportName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "exportName", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "A custom hook — often the bridge between a component and its data." + }, + "DataSourceNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "data-source" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "sourceKind": { + "$ref": "#/definitions/DataSourceKind" + }, + "method": { + "type": [ + "string", + "null" + ], + "description": "HTTP method when statically determinable." + }, + "endpoint": { + "type": "string", + "description": "The endpoint as written in source — may contain template placeholders, e.g. \"/api/users/${id}\"." + } + }, + "required": [ + "endpoint", + "id", + "kind", + "loc", + "method", + "name", + "sourceKind" + ], + "additionalProperties": false, + "description": "An external data origin: an HTTP endpoint, GraphQL operation, or socket." + }, + "DataSourceKind": { + "type": "string", + "enum": [ + "fetch", + "axios", + "react-query", + "swr", + "graphql", + "websocket", + "unknown" + ] + }, + "StateNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "state" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "stateKind": { + "$ref": "#/definitions/StateKind" + } + }, + "required": [ + "id", + "kind", + "loc", + "name", + "stateKind" + ], + "additionalProperties": false, + "description": "Local or global state a component reads." + }, + "StateKind": { + "type": "string", + "enum": [ + "useState", + "useReducer", + "context", + "redux", + "zustand", + "unknown" + ] + }, + "EventNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id, unique within a graph. See nodeId()/instanceId()." + }, + "kind": { + "type": "string", + "const": "event" + }, + "name": { + "type": "string" + }, + "loc": { + "$ref": "#/definitions/SourceLocation" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean." + }, + "event": { + "type": "string", + "description": "e.g. \"onClick\", \"onSubmit\", \"onChange\"" + }, + "handler": { + "type": [ + "string", + "null" + ], + "description": "Name of the handler function, if resolvable." + } + }, + "required": [ + "event", + "handler", + "id", + "kind", + "loc", + "name" + ], + "additionalProperties": false, + "description": "A user or system event a component responds to." + }, + "LineageEdge": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/EdgeKind" + }, + "via": { + "type": "string", + "description": "Prop name for provides-data edges; handler name for triggers." + }, + "condition": { + "$ref": "#/definitions/EdgeCondition" + } + }, + "required": [ + "from", + "to", + "kind" + ], + "additionalProperties": false + }, + "EdgeKind": { + "type": "string", + "enum": [ + "renders", + "instance-of", + "uses-hook", + "fetches-from", + "provides-data", + "reads-state", + "writes-state", + "handles", + "triggers" + ] + }, + "EdgeCondition": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "flag", + "role", + "branch", + "response" + ] + }, + "expression": { + "type": "string", + "description": "Source text of the condition, e.g. `isEnabled(\"new-billing\")`." + } + }, + "required": [ + "kind", + "expression" + ], + "additionalProperties": false, + "description": "A statically-detected condition guarding an edge (feature flag, role, branch)." + } + } +}