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)."
+ }
+ }
+}