Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 0 — Foundations
- **Next step:** 0.3Graph storage & versioning
- **Done:** 0.1, 0.2
- **Next step:** 0.4CI pipeline
- **Done:** 0.1, 0.2, 0.3
- **Gates passed:** none yet (Gate 0 completes with 0.4)

## What CodeRadar is
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
18 changes: 15 additions & 3 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,13 +30,17 @@ program
.argument("<dir>", "directory to scan")
.option("-o, --out <file>", "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<string, number>();
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}`);
Expand Down Expand Up @@ -127,7 +134,12 @@ function loadGraph(file: string): LineageGraph {
console.error(`Graph file not found: ${file} — run \`coderadar scan <dir>\` 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();
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
12 changes: 12 additions & 0 deletions packages/core/scripts/gen-schema.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
20 changes: 20 additions & 0 deletions packages/core/scripts/schema-config.mjs
Original file line number Diff line number Diff line change
@@ -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");
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./types.js";
export * from "./result.js";
export * from "./query.js";
export * from "./storage.js";
67 changes: 67 additions & 0 deletions packages/core/src/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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)));
});
});
63 changes: 63 additions & 0 deletions packages/core/src/storage.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Loading