Skip to content
This repository was archived by the owner on Oct 5, 2026. It is now read-only.
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
5 changes: 4 additions & 1 deletion packages/cli/commands/link.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// forge link — 创建 L2CodeBlock(L3 和 L1 之间的桥接层)
// AI 生成 L1 源代码后运行,创建/更新 L2 映射

import { readL2, readL3, writeL2 } from "../../core/index.js";
import { checkCompatibility, readL2, readL3, writeL2 } from "../../core/index.js";
import { createL2Link, relinkL2 } from "../../skills/index.js";
import type { Command } from "commander";

Expand All @@ -22,6 +22,9 @@ export function registerLink(program: Command): void {
) => {
const root = options.root;

// Ensure schema compatibility
await checkCompatibility(root);

// 读取 L3 contract
const l3 = await readL3(root, l3Id);
if (l3 === null) {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/commands/rehash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// AI 写完 JSON 后运行,自动修正 hash

import {
checkCompatibility,
listL2,
listL3,
listL4,
Expand Down Expand Up @@ -37,6 +38,9 @@ export function registerRehash(program: Command): void {
const results: RehashResult[] = [];
const root = options.root;

// Ensure schema compatibility
await checkCompatibility(root);

// 解析 target
const parsed = parseTarget(target);

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/commands/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 读取 .svp/ 目录下的数据,渲染为 AI 友好的文本视图

import {
checkCompatibility,
listL2,
listL3,
listL4,
Expand All @@ -27,6 +28,7 @@ async function loadAll(root: string): Promise<{
l3Blocks: L3Block[];
l2Blocks: L2CodeBlock[];
}> {
await checkCompatibility(root);
const l5 = (await readL5(root)) ?? undefined;

const l4Ids = await listL4(root);
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
readL3,
readL4,
readL5,
checkCompatibility,
computeSignatureHash,
createTypescriptExtractor,
} from "../core/index.js";
Expand All @@ -27,6 +28,9 @@ export async function loadCheckInput(
root: string,
options: { computeSignatures?: boolean } = {},
): Promise<CheckInput> {
// Ensure .svp/ schema is compatible before reading
await checkCompatibility(root);

const l5 = (await readL5(root)) ?? undefined;

const l4Ids = await listL4(root);
Expand Down
11 changes: 11 additions & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,14 @@ export type {
} from "./skill.js";
export { DEFAULT_SKILL_CONFIG, REVIEW_SKILL_CONFIG } from "./skill.js";
export { t, getLanguage, detectSystemLanguage, languageName, languageDirective } from "./i18n.js";
export type { Manifest, CompatibilityStatus } from "./manifest.js";
export {
SCHEMA_VERSION,
readManifest,
writeManifest,
createManifest,
checkCompatibility,
checkSchemaCompatibility,
} from "./manifest.js";
export type { Migration } from "./migrate.js";
export { runMigrations } from "./migrate.js";
5 changes: 5 additions & 0 deletions packages/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
import { computeHash } from "./hash.js";
import { detectSystemLanguage } from "./i18n.js";
import { createManifest, writeManifest } from "./manifest.js";
import { writeL5 } from "./store.js";
import type { L5Blueprint } from "./l5.js";

Expand Down Expand Up @@ -74,6 +75,10 @@ export async function init(root: string, options: InitOptions): Promise<InitResu

await writeL5(root, l5);

// Write manifest.json
const manifest = createManifest();
await writeManifest(root, manifest);

return { created: true, root, l5 };
}

Expand Down
120 changes: 120 additions & 0 deletions packages/core/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";

Check failure on line 1 in packages/core/manifest.test.ts

View workflow job for this annotation

GitHub Actions / Check (Node 24)

'writeFile' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 1 in packages/core/manifest.test.ts

View workflow job for this annotation

GitHub Actions / Check (Node 24)

'readFile' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 1 in packages/core/manifest.test.ts

View workflow job for this annotation

GitHub Actions / Check (Node 22)

'writeFile' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 1 in packages/core/manifest.test.ts

View workflow job for this annotation

GitHub Actions / Check (Node 22)

'readFile' is defined but never used. Allowed unused vars must match /^_/u
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
SCHEMA_VERSION,
checkCompatibility,
checkSchemaCompatibility,
createManifest,
readManifest,
writeManifest,
} from "./manifest.js";
import type { Manifest } from "./manifest.js";

let testRoot: string;

beforeEach(async () => {
testRoot = path.join(import.meta.dirname, `__test_manifest_${Date.now()}_${Math.random().toString(36).slice(2)}`);

Check failure on line 17 in packages/core/manifest.test.ts

View workflow job for this annotation

GitHub Actions / Check (Node 24)

Invalid type "number" of template literal expression

Check failure on line 17 in packages/core/manifest.test.ts

View workflow job for this annotation

GitHub Actions / Check (Node 22)

Invalid type "number" of template literal expression
await mkdir(path.join(testRoot, ".svp"), { recursive: true });
});

afterEach(async () => {
await rm(testRoot, { recursive: true, force: true });
});

describe("createManifest", () => {
it("creates manifest with current versions", () => {
const manifest = createManifest();
expect(manifest.schemaVersion).toBe(SCHEMA_VERSION);
expect(manifest.forgeVersion).toBeTruthy();
expect(manifest.createdAt).toBeTruthy();
expect(manifest.updatedAt).toBeTruthy();
});
});

describe("readManifest / writeManifest", () => {
it("round-trips manifest through JSON", async () => {
const manifest = createManifest();
await writeManifest(testRoot, manifest);
const loaded = await readManifest(testRoot);
expect(loaded).toEqual(manifest);
});

it("returns null when manifest does not exist", async () => {
const emptyRoot = path.join(testRoot, "empty");
await mkdir(path.join(emptyRoot, ".svp"), { recursive: true });
const loaded = await readManifest(emptyRoot);
expect(loaded).toBeNull();
});
});

describe("checkSchemaCompatibility", () => {
it("returns compatible for same major version", () => {
const manifest: Manifest = {
schemaVersion: SCHEMA_VERSION,
forgeVersion: "0.1.0",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const status = checkSchemaCompatibility(manifest);
expect(status.compatible).toBe(true);
});

it("returns compatible for same major, different minor/patch", () => {
const manifest: Manifest = {
schemaVersion: "1.2.3",
forgeVersion: "0.1.0",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Both have major 1
const status = checkSchemaCompatibility(manifest);
expect(status.compatible).toBe(true);
});

it("returns incompatible when manifest major > current major (downgrade)", () => {
const manifest: Manifest = {
schemaVersion: "99.0.0",
forgeVersion: "99.0.0",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const status = checkSchemaCompatibility(manifest);
expect(status.compatible).toBe(false);
if (!status.compatible) {
expect(status.reason).toContain("upgrade forge");
}
});
});

describe("checkCompatibility", () => {
it("auto-creates manifest for legacy project (missing manifest.json)", async () => {
// .svp/ exists but no manifest.json
const manifest = await checkCompatibility(testRoot);
expect(manifest.schemaVersion).toBe(SCHEMA_VERSION);

// Verify it was written to disk
const onDisk = await readManifest(testRoot);
expect(onDisk).toEqual(manifest);
});

it("accepts a project with current schema version", async () => {
const original = createManifest();
await writeManifest(testRoot, original);

const manifest = await checkCompatibility(testRoot);
expect(manifest.schemaVersion).toBe(SCHEMA_VERSION);
});

it("throws on downgrade (manifest major > current)", async () => {
const future: Manifest = {
schemaVersion: "99.0.0",
forgeVersion: "99.0.0",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await writeManifest(testRoot, future);

await expect(checkCompatibility(testRoot)).rejects.toThrow("upgrade forge");
});
});
146 changes: 146 additions & 0 deletions packages/core/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// .svp/manifest.json — schema versioning and compatibility checks

import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { VERSION } from "./version.js";

/** Current schema version for the .svp/ data model */
export const SCHEMA_VERSION = "1.0.0";

export interface Manifest {
readonly schemaVersion: string;
readonly forgeVersion: string;
readonly createdAt: string;
readonly updatedAt: string;
}

export type CompatibilityStatus =
| { readonly compatible: true }
| { readonly compatible: false; readonly reason: string };

const SVP_DIR = ".svp";
const MANIFEST_FILE = "manifest.json";

function manifestPath(root: string): string {
return path.join(root, SVP_DIR, MANIFEST_FILE);
}

/** Parse major version number from a semver string */
function major(version: string): number {
const m = /^(\d+)/.exec(version);
return m ? Number(m[1]) : 0;
Comment on lines +30 to +31
}

/** Read manifest.json, returns null if missing */
export async function readManifest(root: string): Promise<Manifest | null> {
try {
const content = await readFile(manifestPath(root), "utf8");
return JSON.parse(content) as Manifest;
} catch {
return null;
Comment on lines +36 to +40
}
}

/** Write manifest.json */
export async function writeManifest(root: string, manifest: Manifest): Promise<void> {
await writeFile(manifestPath(root), JSON.stringify(manifest, null, 2) + "\n", "utf8");
}
Comment on lines +44 to +47

/** Create a fresh manifest with current versions */
export function createManifest(): Manifest {
const now = new Date().toISOString();
return {
schemaVersion: SCHEMA_VERSION,
forgeVersion: VERSION,
createdAt: now,
updatedAt: now,
};
}

/** Update the manifest's forgeVersion and updatedAt timestamp */
export function touchManifest(manifest: Manifest): Manifest {
return {
...manifest,
forgeVersion: VERSION,
updatedAt: new Date().toISOString(),
};
}

/**
* Check compatibility between manifest schema version and current SCHEMA_VERSION.
* Returns a status object indicating whether the project is compatible.
*/
export function checkSchemaCompatibility(manifest: Manifest): CompatibilityStatus {
const currentMajor = major(SCHEMA_VERSION);
const manifestMajor = major(manifest.schemaVersion);

if (manifestMajor === currentMajor) {
return { compatible: true };
}

if (manifestMajor > currentMajor) {
return {
compatible: false,
reason:
`This project uses schema v${manifest.schemaVersion} but forge v${VERSION} ` +
`only supports schema v${SCHEMA_VERSION}. Please upgrade forge.`,
};
}

// manifestMajor < currentMajor → migration needed
// For now this is treated as compatible since migration will handle it.
// The migrate module will do the actual work.
return { compatible: true };
}

/**
* Ensure the .svp/ directory has a valid, compatible manifest.
* - Missing manifest → auto-create (legacy project)
* - Incompatible → throw with reason
* - Needs migration → run migrations, update manifest
*
* Returns the (possibly updated) manifest.
*/
export async function checkCompatibility(root: string): Promise<Manifest> {
// If .svp/ doesn't exist at all, nothing to check (not an initialized project)
try {
const s = await stat(path.join(root, SVP_DIR));
if (!s.isDirectory()) return createManifest();
} catch {
return createManifest();
}

let manifest = await readManifest(root);

if (manifest === null) {
// Legacy project without manifest — create one at v1.0.0
manifest = createManifest();
await writeManifest(root, manifest);
return manifest;
}

const status = checkSchemaCompatibility(manifest);
if (!status.compatible) {
throw new Error(status.reason);
}

const currentMajor = major(SCHEMA_VERSION);
const manifestMajor = major(manifest.schemaVersion);

if (manifestMajor < currentMajor) {
// Run migrations
const { runMigrations } = await import("./migrate.js");
await runMigrations(root, manifestMajor, currentMajor);

// Update manifest to current schema
manifest = {
...manifest,
schemaVersion: SCHEMA_VERSION,
forgeVersion: VERSION,
updatedAt: new Date().toISOString(),
};
await writeManifest(root, manifest);
}

return manifest;
}
14 changes: 14 additions & 0 deletions packages/core/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { runMigrations } from "./migrate.js";

describe("runMigrations", () => {
it("is a no-op when from === to", async () => {
// Should not throw — nothing to do
await runMigrations("/unused", 1, 1);
});

it("throws when migration is missing", async () => {
// No migration from v1 to v2 exists in the empty registry
await expect(runMigrations("/unused", 1, 2)).rejects.toThrow("No migration found");
});
});
Loading
Loading