diff --git a/README.md b/README.md index ca24921..ee102e0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,13 @@ SVP doesn't call AI APIs or build compilers. SVP is an **enhancement layer** for - **Toolchain**: `forge check` (validation), store (read/write), hash (change tracking) - **Skills**: Generate structured context from the five-layer data model, fed into your existing AI tools -Similar to [OpenSpec](https://github.com/Fission-AI/OpenSpec) — we don't build AI, we feed AI better context. SVP's capabilities improve automatically as base models evolve. +We don't build AI, we feed AI better context. SVP's capabilities improve automatically as base models evolve. + +### Works great with OpenSpec + +[OpenSpec](https://github.com/Fission-AI/OpenSpec) focuses on **spec before code** — making sure requirements are clear before AI writes anything. SVP focuses on **verify after code** — making sure what AI wrote is consistent and correct across layers. + +They're complementary: OpenSpec manages the input quality (what to build), SVP manages the output quality (was it built right). Use both for a complete spec → architecture → verification pipeline. ## Design Principles diff --git a/i18n/zh/README.md b/i18n/zh/README.md index 6ec329a..ea697bc 100644 --- a/i18n/zh/README.md +++ b/i18n/zh/README.md @@ -25,7 +25,13 @@ SVP 不自己调 AI API,不造编译器。SVP 是 AI 编码工具(Claude Cod - **工具链**:`forge check`(校验)、store(读写)、hash(变更追踪) - **Skills**:基于五层数据模型生成结构化 context,喂给用户已有的 AI 工具 -类似 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 的定位——不造 AI,给 AI 喂更好的上下文。SVP 的能力随 base model 进化自动提升。 +不造 AI,给 AI 喂更好的上下文。SVP 的能力随 base model 进化自动提升。 + +### 推荐搭配 OpenSpec 使用 + +[OpenSpec](https://github.com/Fission-AI/OpenSpec) 专注**写代码之前**——确保需求规范清晰再让 AI 动手。SVP 专注**写代码之后**——确保 AI 写出来的东西在各层之间一致且正确。 + +两者互补:OpenSpec 管输入质量(要造什么),SVP 管输出质量(造对了没)。搭配使用可以形成完整的 需求 → 架构 → 验证 流水线。 ## 设计原则 diff --git a/packages/__tests__/e2e.test.ts b/packages/__tests__/e2e.test.ts index 5592184..7671d34 100644 --- a/packages/__tests__/e2e.test.ts +++ b/packages/__tests__/e2e.test.ts @@ -149,11 +149,11 @@ describe("E2E: SVP Pipeline", () => { }); await writeL2(tmpDir, l2); - // Verify initially no issues + // Verify initially no errors (1 warning for missing node docs is expected) let input = await loadCheckInput(tmpDir); let report = check(input); expect(report.summary.errors).toBe(0); - expect(report.summary.warnings).toBe(0); + expect(report.issues.filter((i) => i.code === "MISSING_NODE_DOCS")).toHaveLength(1); // Modify L3 content (add a constraint) and rehash const modifiedL3 = makeL3({ diff --git a/packages/cli/commands/changeset.ts b/packages/cli/commands/changeset.ts new file mode 100644 index 0000000..875d620 --- /dev/null +++ b/packages/cli/commands/changeset.ts @@ -0,0 +1,192 @@ +// forge changeset — cross-artifact version grouping +// Start, complete, list, view, and abandon changesets + +import { + computeBaselineFromArtifacts, + computeDiff, + findActiveChangeset, + formatDiffSummary, + listChangesets, + readChangeset, + writeChangeset, + deleteChangeset, +} from "../../core/index.js"; +import { loadCheckInput } from "../load.js"; +import type { Changeset } from "../../core/index.js"; +import type { Command } from "commander"; + +/** Convert a name to kebab-case id */ +function toId(name: string): string { + return name + .toLowerCase() + .trim() + .replaceAll(/[^\da-z]+/g, "-") + .replaceAll(/^-+|-+$/g, ""); +} + +/** Build current rev map from CheckInput (same shape as baseline) */ +function currentRevsFromInput( + input: Awaited>, +): Record { + return computeBaselineFromArtifacts(input); +} + +export function registerChangeset(program: Command): void { + const cmd = program.command("changeset").description("Cross-artifact version grouping"); + + // ── start ── + cmd + .command("start ") + .description("Start a new changeset (snapshot baseline)") + .requiredOption("--reason ", "Why this change is being made") + .option("-r, --root ", "Project root directory", ".") + .action(async (name: string, options: { reason: string; root: string }) => { + const existing = await findActiveChangeset(options.root); + if (existing !== null) { + console.error( + `Error: Active changeset "${existing.id}" already exists. Complete or abandon it first.`, + ); + process.exitCode = 1; + return; + } + + const input = await loadCheckInput(options.root); + const baseline = computeBaselineFromArtifacts(input); + const id = toId(name); + + const cs: Changeset = { + id, + name, + reason: options.reason, + status: "active", + baseline, + createdAt: new Date().toISOString(), + }; + + await writeChangeset(options.root, cs); + console.log(`Changeset "${id}" started.`); + console.log(`Baseline: ${String(Object.keys(baseline).length)} artifact(s) captured.`); + }); + + // ── complete ── + cmd + .command("complete") + .description("Complete the active changeset") + .option("-r, --root ", "Project root directory", ".") + .action(async (options: { root: string }) => { + const active = await findActiveChangeset(options.root); + if (active === null) { + console.error("Error: No active changeset to complete."); + process.exitCode = 1; + return; + } + + const input = await loadCheckInput(options.root); + const current = currentRevsFromInput(input); + const diff = computeDiff(active.baseline, current); + + const completed: Changeset = { + ...active, + status: "completed", + completedAt: new Date().toISOString(), + }; + await writeChangeset(options.root, completed); + + console.log(`Changeset "${active.id}" completed.`); + console.log(); + console.log(formatDiffSummary(diff)); + }); + + // ── list ── + cmd + .command("list") + .description("List all changesets") + .option("-r, --root ", "Project root directory", ".") + .action(async (options: { root: string }) => { + const ids = await listChangesets(options.root); + if (ids.length === 0) { + console.log("No changesets found."); + return; + } + + // Load all, show active first + const all: Changeset[] = []; + for (const id of ids) { + const cs = await readChangeset(options.root, id); + if (cs !== null) all.push(cs); + } + + const active = all.filter((c) => c.status === "active"); + const completed = all.filter((c) => c.status === "completed"); + + for (const cs of [...active, ...completed]) { + const marker = cs.status === "active" ? "* " : " "; + const date = + cs.status === "completed" && cs.completedAt !== undefined + ? ` (completed ${cs.completedAt.slice(0, 10)})` + : ""; + console.log(`${marker}${cs.id} — ${cs.reason}${date}`); + } + }); + + // ── view ── + cmd + .command("view [id]") + .description("View changeset diff (defaults to active)") + .option("-r, --root ", "Project root directory", ".") + .action(async (id: string | undefined, options: { root: string }) => { + const cs = + id === undefined + ? await findActiveChangeset(options.root) + : await readChangeset(options.root, id); + + if (cs === null) { + const msg = + id === undefined ? "Error: No active changeset." : `Error: Changeset "${id}" not found.`; + console.error(msg); + process.exitCode = 1; + return; + } + + const input = await loadCheckInput(options.root); + const current = currentRevsFromInput(input); + const diff = computeDiff(cs.baseline, current); + + console.log(`Changeset: ${cs.id}`); + console.log(`Reason: ${cs.reason}`); + console.log(`Status: ${cs.status}`); + console.log(); + console.log(formatDiffSummary(diff)); + }); + + // ── abandon ── + cmd + .command("abandon [id]") + .description("Delete an active changeset") + .option("-r, --root ", "Project root directory", ".") + .action(async (id: string | undefined, options: { root: string }) => { + const cs = + id === undefined + ? await findActiveChangeset(options.root) + : await readChangeset(options.root, id); + + if (cs === null) { + const msg = + id === undefined + ? "Error: No active changeset to abandon." + : `Error: Changeset "${id}" not found.`; + console.error(msg); + process.exitCode = 1; + return; + } + + if (cs.status !== "active") { + console.error(`Error: Changeset "${cs.id}" is already completed. Cannot abandon.`); + process.exitCode = 1; + return; + } + + await deleteChangeset(options.root, cs.id); + console.log(`Changeset "${cs.id}" abandoned.`); + }); +} diff --git a/packages/cli/commands/docs.ts b/packages/cli/commands/docs.ts new file mode 100644 index 0000000..875db13 --- /dev/null +++ b/packages/cli/commands/docs.ts @@ -0,0 +1,183 @@ +// forge docs — 文档质量检查 CLI 命令 +// list: 列出已有文档 check: 检查文档覆盖率 + +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { checkDocs } from "../../core/docs.js"; +import { loadCheckInput } from "../load.js"; +import type { DocsCheckInput } from "../../core/docs.js"; +import type { Command } from "commander"; + +/** Scan nodes/{id}/docs.md, return nodeId set with existing docs */ +async function scanNodeDocs(root: string): Promise> { + const result = new Map(); + const nodesDir = path.join(root, "nodes"); + try { + const entries = await readdir(nodesDir); + for (const entry of entries) { + const docsPath = path.join(nodesDir, entry, "docs.md"); + try { + const s = await stat(docsPath); + if (s.isFile()) result.set(entry, true); + } catch { + // docs.md doesn't exist for this node + } + } + } catch { + // nodes/ directory doesn't exist + } + return result; +} + +/** Scan graphs/{name}.docs.md, return graphName set with existing docs */ +async function scanGraphDocs(root: string): Promise> { + const result = new Map(); + const graphsDir = path.join(root, "graphs"); + try { + const entries = await readdir(graphsDir); + for (const entry of entries) { + if (entry.endsWith(".docs.md")) { + const graphName = entry.slice(0, -".docs.md".length); + result.set(graphName, true); + } + } + } catch { + // graphs/ directory doesn't exist + } + return result; +} + +/** 检查 docs/l5.md 是否存在 */ +async function hasL5Docs(root: string): Promise { + try { + const s = await stat(path.join(root, "docs", "l5.md")); + return s.isFile(); + } catch { + return false; + } +} + +/** 注册 forge docs 子命令组 */ +export function registerDocs(program: Command): void { + const docs = program.command("docs").description("Documentation quality tools"); + + // ── forge docs list ── + docs + .command("list") + .description("List all existing documentation files and their linked artifacts") + .option("-r, --root ", "Project root directory", ".") + .action(async (options: { root: string }) => { + const root = options.root; + const nodes = await scanNodeDocs(root); + const graphs = await scanGraphDocs(root); + const l5 = await hasL5Docs(root); + + const rows: Array<{ path: string; artifact: string; status: string }> = []; + + if (l5) { + rows.push({ path: "docs/l5.md", artifact: "L5 blueprint", status: "ok" }); + } + + for (const [nodeId] of nodes) { + rows.push({ + path: `nodes/${nodeId}/docs.md`, + artifact: `L3 block "${nodeId}"`, + status: "ok", + }); + } + + for (const [graphName] of graphs) { + rows.push({ + path: `graphs/${graphName}.docs.md`, + artifact: `L4 artifact "${graphName}"`, + status: "ok", + }); + } + + if (rows.length === 0) { + console.log("No documentation files found."); + console.log("Create docs with: nodes//docs.md or graphs/.docs.md"); + return; + } + + // Simple table output + const pathWidth = Math.max(4, ...rows.map((r) => r.path.length)); + const artWidth = Math.max(8, ...rows.map((r) => r.artifact.length)); + + console.log(`${"PATH".padEnd(pathWidth)} ${"ARTIFACT".padEnd(artWidth)} STATUS`); + console.log(`${"─".repeat(pathWidth)} ${"─".repeat(artWidth)} ──────`); + for (const row of rows) { + console.log( + `${row.path.padEnd(pathWidth)} ${row.artifact.padEnd(artWidth)} ${row.status}`, + ); + } + }); + + // ── forge docs check ── + docs + .command("check") + .description("Check documentation coverage against .svp/ artifacts") + .option("-r, --root ", "Project root directory", ".") + .option("--json", "Output results as JSON") + .action(async (options: { root: string; json?: boolean }) => { + const root = options.root; + + let input; + try { + input = await loadCheckInput(root); + } catch { + console.error(`Error: cannot load .svp/ data from "${root}". Run \`forge init\` first.`); + process.exitCode = 1; + return; + } + + const [nodes, graphs, l5] = await Promise.all([ + scanNodeDocs(root), + scanGraphDocs(root), + hasL5Docs(root), + ]); + + const docsInput: DocsCheckInput = { + l5: input.l5, + l4Flows: input.l4Flows, + l3Blocks: input.l3Blocks, + l2Blocks: input.l2Blocks, + existingDocs: { l5, nodes, graphs }, + }; + + const issues = checkDocs(docsInput); + + // Calculate coverage + const totalArtifacts = + (input.l5 === undefined ? 0 : 1) + input.l3Blocks.length + input.l4Flows.length; + const documented = + (input.l5 === undefined ? 0 : l5 ? 1 : 0) + + input.l3Blocks.filter((b) => nodes.has(b.id)).length + + input.l4Flows.filter((f) => graphs.has(f.id)).length; + const coverage = totalArtifacts === 0 ? 100 : Math.round((documented / totalArtifacts) * 100); + + if (options.json === true) { + console.log( + JSON.stringify({ issues, coverage, total: totalArtifacts, documented }, null, 2), + ); + return; + } + + if (issues.length === 0) { + console.log( + `Documentation coverage: ${String(coverage)}% (${String(documented)}/${String(totalArtifacts)} artifacts)`, + ); + console.log("All artifacts have documentation."); + return; + } + + console.log( + `Documentation coverage: ${String(coverage)}% (${String(documented)}/${String(totalArtifacts)} artifacts)`, + ); + console.log(); + console.log("Missing documentation:"); + for (const issue of issues) { + console.log(` [${issue.layer}] ${issue.message}`); + } + }); +} diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index b486b87..536c757 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -151,6 +151,8 @@ export function registerInit(program: Command): void { console.log(" ├── l4/ (logic chains)"); console.log(" ├── l3/ (logic blocks)"); console.log(" └── l2/ (code blocks)"); + console.log(" nodes/ (module docs)"); + console.log(" graphs/ (graph docs)"); // Host-specific integration if (hostIds.length === 0) { diff --git a/packages/cli/commands/link.ts b/packages/cli/commands/link.ts index 9451283..39efe4f 100644 --- a/packages/cli/commands/link.ts +++ b/packages/cli/commands/link.ts @@ -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"; @@ -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) { diff --git a/packages/cli/commands/prompt.ts b/packages/cli/commands/prompt.ts index 1edaa58..74f775a 100644 --- a/packages/cli/commands/prompt.ts +++ b/packages/cli/commands/prompt.ts @@ -1,17 +1,25 @@ // forge prompt — 生成上下文感知的 AI 提示词 -// 7 个子命令:compile, recompile, review, update-ref, design-l5, design-l4, design-l3 +// 8 个子命令:compile, recompile, review, update-ref, design-l5, design-l4, design-l3, scan // 读 .svp/ 状态 → 解析上下文 → 调用 prompt builder → stdout 输出 markdown +import path from "node:path"; import { getDefaultComplexity } from "../../core/compile-plan.js"; import { getLanguage } from "../../core/i18n.js"; import { extractBlockRefs, findBlockContext, getL4Kind } from "../../core/l4.js"; +import { collectScanContext } from "../../core/scan.js"; import { DEFAULT_SKILL_CONFIG } from "../../core/skill.js"; +import { readGraphDocs, readL5Docs, readNodeDocs } from "../../core/store.js"; import { buildPrompt, renderPrompt } from "../../skills/prompt-builder.js"; import { buildDesignL3Prompt } from "../../skills/prompts/design-l3.js"; import { buildDesignL4EventGraphPrompt } from "../../skills/prompts/design-l4-event-graph.js"; import { buildDesignL4StateMachinePrompt } from "../../skills/prompts/design-l4-state-machine.js"; import { buildDesignL4Prompt } from "../../skills/prompts/design-l4.js"; import { buildDesignL5Prompt } from "../../skills/prompts/design-l5.js"; +import { + buildScanL3Prompt, + buildScanL4Prompt, + buildScanL5Prompt, +} from "../../skills/prompts/scan.js"; import { loadCheckInput } from "../load.js"; import { createResolver } from "../resolve.js"; import type { CompileTask, ContextRef, TaskAction } from "../../core/compile-plan.js"; @@ -62,6 +70,7 @@ export function registerPrompt(program: Command): void { registerDesignL5(prompt); registerDesignL4(prompt); registerDesignL3(prompt); + registerScan(prompt); } // ── Task-based prompt helpers ── @@ -228,10 +237,12 @@ function registerDesignL5(parent: Command): void { input = { l5: undefined, l4Flows: [], l3Blocks: [], l2Blocks: [] }; } + const l5Docs = await readL5Docs(root); const prompt = buildDesignL5Prompt({ currentL5: input.l5, userIntent: options.intent, language: getLanguage(input.l5), + docs: l5Docs ?? undefined, }); console.log(prompt); @@ -278,6 +289,7 @@ function registerDesignL4(parent: Command): void { return; } + const graphDocs = targetId === undefined ? null : await readGraphDocs(root, targetId); let prompt: string; if (kind === "event-graph") { @@ -288,6 +300,7 @@ function registerDesignL4(parent: Command): void { userIntent: options.intent, targetId, language: getLanguage(input.l5), + docs: graphDocs ?? undefined, }); } else if (kind === "state-machine") { prompt = buildDesignL4StateMachinePrompt({ @@ -297,6 +310,7 @@ function registerDesignL4(parent: Command): void { userIntent: options.intent, targetId, language: getLanguage(input.l5), + docs: graphDocs ?? undefined, }); } else { prompt = buildDesignL4Prompt({ @@ -306,6 +320,7 @@ function registerDesignL4(parent: Command): void { userIntent: options.intent, targetFlowId: targetId, language: getLanguage(input.l5), + docs: graphDocs ?? undefined, }); } @@ -369,12 +384,14 @@ function registerDesignL3(parent: Command): void { const nextBlock = stepIndex < flow.steps.length - 1 ? findBlock(stepIndex + 1) : undefined; const existingBlock = input.l3Blocks.find((b) => b.id === blockId); + const nodeDocs = await readNodeDocs(root, blockId); const prompt = buildDesignL3Prompt({ l4Context: { flow, stepIndex, prevBlock, nextBlock }, existingBlock, userIntent: options.intent, language: getLanguage(input.l5), + docs: nodeDocs ?? undefined, }); console.log(prompt); @@ -401,15 +418,102 @@ function registerDesignL3(parent: Command): void { ? undefined : input.l3Blocks.find((b) => b.id === blockContext.nextBlockRef); const existingBlock = input.l3Blocks.find((b) => b.id === blockId); + const nodeDocs2 = await readNodeDocs(root, blockId); const prompt = buildDesignL3Prompt({ l4Context: { l4, blockContext, prevBlock, nextBlock }, existingBlock, userIntent: options.intent, language: getLanguage(input.l5), + docs: nodeDocs2 ?? undefined, }); console.log(prompt); }, ); } + +// ── Scan (brownfield reverse generation) ── + +function registerScan(parent: Command): void { + parent + .command("scan") + .description("Generate reverse-engineering prompt (auto-detects phase from .svp/ state)") + .option("--dir ", "Directory to scan", "src") + .option("--intent ", "Optional: describe what the system does") + .option("--max-files ", "Max files to scan", "50") + .option("-r, --root ", "Project root directory", ".") + .action(async (options: { dir: string; intent?: string; maxFiles: string; root: string }) => { + const root = options.root; + const maxFiles = Number.parseInt(options.maxFiles, 10); + + // Determine scan directory: use "src" if it exists, else "." + let scanDir = options.dir; + if (scanDir === "src") { + try { + const s = await import("node:fs/promises").then((fs) => + fs.stat(path.resolve(root, "src")), + ); + if (!s.isDirectory()) scanDir = "."; + } catch { + scanDir = "."; + } + } + + // Collect scan context (with TS extractor for signature extraction) + const { createTypescriptExtractor } = await import("../../core/extractors/typescript.js"); + const extractor = createTypescriptExtractor(); + const scanContext = await collectScanContext({ root, dir: scanDir, maxFiles }, extractor); + + if (scanContext.files.length === 0) { + console.error( + `Error: no files found in "${scanDir}" (relative to "${root}"). Check --dir path.`, + ); + process.exitCode = 1; + return; + } + + // Load existing .svp/ state to detect phase + let input; + try { + input = await loadCheckInput(root); + } catch { + // No .svp/ yet — that's okay, Phase 1 doesn't need it + input = { l5: undefined, l4Flows: [], l3Blocks: [], l2Blocks: [] }; + } + + const language = getLanguage(input.l5); + const userIntent = options.intent; + + // Auto-detect phase from .svp/ state + if (input.l3Blocks.length === 0) { + // Phase 1: no L3 → generate L3 from code + const prompt = buildScanL3Prompt({ scanContext, userIntent, language }); + console.log(prompt); + } else if (input.l4Flows.length === 0) { + // Phase 2: has L3 but no L4 → generate L4 from L3 + const prompt = buildScanL4Prompt({ + scanContext, + l3Blocks: input.l3Blocks, + userIntent, + language, + }); + console.log(prompt); + } else if (input.l5 === undefined) { + // Phase 3: has L3+L4 but no L5 → generate L5 + const prompt = buildScanL5Prompt({ + scanContext, + l3Blocks: input.l3Blocks, + l4Flows: input.l4Flows, + userIntent, + language, + }); + console.log(prompt); + } else { + // All layers present — scan complete + console.log( + "Scan complete: .svp/ already has L3, L4, and L5 artifacts.\nRun `forge check` to verify consistency.", + ); + } + }); +} diff --git a/packages/cli/commands/rehash.ts b/packages/cli/commands/rehash.ts index 4a21da0..5a8787b 100644 --- a/packages/cli/commands/rehash.ts +++ b/packages/cli/commands/rehash.ts @@ -2,6 +2,7 @@ // AI 写完 JSON 后运行,自动修正 hash import { + checkCompatibility, listL2, listL3, listL4, @@ -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); diff --git a/packages/cli/commands/view.ts b/packages/cli/commands/view.ts index aea4114..445f485 100644 --- a/packages/cli/commands/view.ts +++ b/packages/cli/commands/view.ts @@ -2,6 +2,7 @@ // 读取 .svp/ 目录下的数据,渲染为 AI 友好的文本视图 import { + checkCompatibility, listL2, listL3, listL4, @@ -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); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 9bb004a..11a5c71 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -3,8 +3,10 @@ import { Command } from "commander"; import { VERSION } from "../core/version.js"; +import { registerChangeset } from "./commands/changeset.js"; import { registerCheck } from "./commands/check.js"; import { registerCompilePlan } from "./commands/compile-plan.js"; +import { registerDocs } from "./commands/docs.js"; import { registerFix } from "./commands/fix.js"; import { registerInit } from "./commands/init.js"; import { registerLink } from "./commands/link.js"; @@ -19,8 +21,10 @@ export function createCLI(): Command { .description("SVP — Semantic Voxel Protocol toolchain") .version(VERSION); + registerChangeset(program); registerCheck(program); registerCompilePlan(program); + registerDocs(program); registerInit(program); registerLink(program); registerRehash(program); diff --git a/packages/cli/load.ts b/packages/cli/load.ts index 0d0865b..b343bd3 100644 --- a/packages/cli/load.ts +++ b/packages/cli/load.ts @@ -1,7 +1,7 @@ // 共享的 .svp/ 数据加载逻辑 // 两个命令(check, compile-plan)复用 -import { stat } from "node:fs/promises"; +import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { listL2, @@ -11,6 +11,7 @@ import { readL3, readL4, readL5, + checkCompatibility, computeSignatureHash, createTypescriptExtractor, } from "../core/index.js"; @@ -27,6 +28,9 @@ export async function loadCheckInput( root: string, options: { computeSignatures?: boolean } = {}, ): Promise { + // Ensure .svp/ schema is compatible before reading + await checkCompatibility(root); + const l5 = (await readL5(root)) ?? undefined; const l4Ids = await listL4(root); @@ -56,7 +60,30 @@ export async function loadCheckInput( l1SignatureHashes = await computeL1Signatures(root, l2Blocks); } - return { l5, l4Flows, l3Blocks, l2Blocks, l1SignatureHashes }; + // 扫描 nodes/ 目录收集已有文档列表 + const existingNodeDocs = await scanExistingNodeDocs(root); + + return { l5, l4Flows, l3Blocks, l2Blocks, l1SignatureHashes, existingNodeDocs }; +} + +/** Scan nodes/{id}/docs.md, return nodeId set with existing docs */ +async function scanExistingNodeDocs(root: string): Promise> { + const result = new Set(); + const nodesDir = path.join(root, "nodes"); + try { + const entries = await readdir(nodesDir); + for (const entry of entries) { + try { + const s = await stat(path.join(nodesDir, entry, "docs.md")); + if (s.isFile()) result.add(entry); + } catch { + // docs.md doesn't exist for this node + } + } + } catch { + // nodes/ directory doesn't exist + } + return result; } /** 遍历 L2 blocks,提取 L1 文件的导出签名并计算聚合 hash */ diff --git a/packages/cli/resolve.ts b/packages/cli/resolve.ts index e3cfbf8..6231ba5 100644 --- a/packages/cli/resolve.ts +++ b/packages/cli/resolve.ts @@ -2,8 +2,14 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { readGraphDocs, readNodeDocs } from "../core/index.js"; -import type { CheckInput, CompileTask, FileContent, ResolvedContext } from "../core/index.js"; +import { readGraphDocs, readNodeDocs, readNodeRefs, readGraphRefs } from "../core/index.js"; +import type { + CheckInput, + CompileTask, + FileContent, + RefFile, + ResolvedContext, +} from "../core/index.js"; export interface ContextResolver { readonly resolve: (task: CompileTask, input: CheckInput) => Promise; @@ -20,6 +26,7 @@ export function createResolver(root: string): ContextResolver { l4?: ResolvedContext["l4"]; l1Files?: FileContent[]; docs?: string; + refs?: RefFile[]; } = {}; for (const ref of task.context) { @@ -54,8 +61,10 @@ export function createResolver(root: string): ContextResolver { const l4Ref = task.context.find((r) => r.layer === "l4"); if (l3Ref !== undefined) { ctx.docs = (await readNodeDocs(root, l3Ref.id)) ?? undefined; + ctx.refs = await readNodeRefs(root, l3Ref.id); } else if (l4Ref !== undefined) { ctx.docs = (await readGraphDocs(root, l4Ref.id)) ?? undefined; + ctx.refs = await readGraphRefs(root, l4Ref.id); } } diff --git a/packages/core/changeset.test.ts b/packages/core/changeset.test.ts new file mode 100644 index 0000000..69679a3 --- /dev/null +++ b/packages/core/changeset.test.ts @@ -0,0 +1,291 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { computeBaselineFromArtifacts, computeDiff, formatDiffSummary } from "./changeset.js"; +import { + deleteChangeset, + findActiveChangeset, + listChangesets, + readChangeset, + writeChangeset, +} from "./store.js"; +import type { Changeset } from "./changeset.js"; +import type { CheckInput } from "./check.js"; +import type { ArtifactVersion } from "./version.js"; + +// ── Helpers ── + +const REV: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00.000Z", +}; + +const makeRev = (rev: number): ArtifactVersion => ({ + ...REV, + rev, +}); + +const makeInput = (overrides?: Partial): CheckInput => ({ + l5: { + id: "my-project", + name: "My Project", + version: "1.0.0", + intent: "test", + constraints: [], + domains: [], + integrations: [], + revision: makeRev(3), + contentHash: "h5", + }, + l4Flows: [ + { + id: "checkout-flow", + name: "Checkout", + steps: [{ id: "s1", action: "process", blockRef: "validate-order", next: null }], + dataFlows: [], + revision: makeRev(2), + contentHash: "h4", + }, + ], + l3Blocks: [ + { + id: "validate-order", + name: "Validate Order", + input: [{ name: "order", type: "Order" }], + output: [{ name: "result", type: "ValidationResult" }], + validate: {}, + constraints: [], + description: "Validates an order", + revision: makeRev(2), + contentHash: "h3", + }, + ], + l2Blocks: [], + ...overrides, +}); + +// ── computeBaselineFromArtifacts ── + +describe("computeBaselineFromArtifacts", () => { + it("captures all artifact revs", () => { + const input = makeInput(); + const baseline = computeBaselineFromArtifacts(input); + + expect(baseline).toEqual({ + "l5:my-project": 3, + "l4:checkout-flow": 2, + "l3:validate-order": 2, + }); + }); + + it("handles empty input", () => { + const input: CheckInput = { + l4Flows: [], + l3Blocks: [], + l2Blocks: [], + }; + const baseline = computeBaselineFromArtifacts(input); + expect(baseline).toEqual({}); + }); + + it("includes L2 blocks when present", () => { + const input = makeInput({ + l2Blocks: [ + { + id: "validate-order-impl", + blockRef: "validate-order", + language: "typescript", + files: ["src/validate.ts"], + sourceHash: "sh", + contentHash: "ch", + revision: makeRev(1), + }, + ], + }); + const baseline = computeBaselineFromArtifacts(input); + expect(baseline["l2:validate-order-impl"]).toBe(1); + }); +}); + +// ── computeDiff ── + +describe("computeDiff", () => { + it("detects created artifacts", () => { + const baseline = { "l5:proj": 1 }; + const current = { "l5:proj": 1, "l3:new-block": 1 }; + const diff = computeDiff(baseline, current); + + expect(diff.created).toEqual([{ layer: "l3", id: "new-block", currentRev: 1 }]); + expect(diff.unchanged).toEqual([{ layer: "l5", id: "proj", rev: 1 }]); + expect(diff.modified).toEqual([]); + }); + + it("detects modified artifacts", () => { + const baseline = { "l3:validate-order": 2 }; + const current = { "l3:validate-order": 4 }; + const diff = computeDiff(baseline, current); + + expect(diff.modified).toEqual([{ layer: "l3", id: "validate-order", fromRev: 2, toRev: 4 }]); + expect(diff.created).toEqual([]); + expect(diff.unchanged).toEqual([]); + }); + + it("detects unchanged artifacts", () => { + const baseline = { "l5:proj": 3, "l4:flow": 2 }; + const current = { "l5:proj": 3, "l4:flow": 2 }; + const diff = computeDiff(baseline, current); + + expect(diff.unchanged).toHaveLength(2); + expect(diff.created).toEqual([]); + expect(diff.modified).toEqual([]); + }); + + it("handles empty baseline (all created)", () => { + const baseline = {}; + const current = { "l5:proj": 1, "l3:block": 1 }; + const diff = computeDiff(baseline, current); + + expect(diff.created).toHaveLength(2); + expect(diff.modified).toEqual([]); + expect(diff.unchanged).toEqual([]); + }); + + it("handles empty current", () => { + const baseline = { "l5:proj": 1 }; + const current = {}; + const diff = computeDiff(baseline, current); + + expect(diff.created).toEqual([]); + expect(diff.modified).toEqual([]); + expect(diff.unchanged).toEqual([]); + }); + + it("handles mixed scenario", () => { + const baseline = { "l5:proj": 1, "l3:a": 2, "l4:flow": 3 }; + const current = { "l5:proj": 1, "l3:a": 5, "l4:flow": 3, "l3:b": 1 }; + const diff = computeDiff(baseline, current); + + expect(diff.created).toEqual([{ layer: "l3", id: "b", currentRev: 1 }]); + expect(diff.modified).toEqual([{ layer: "l3", id: "a", fromRev: 2, toRev: 5 }]); + expect(diff.unchanged).toHaveLength(2); + }); +}); + +// ── formatDiffSummary ── + +describe("formatDiffSummary", () => { + it("formats created, modified, unchanged", () => { + const output = formatDiffSummary({ + created: [{ layer: "l3", id: "new-block", currentRev: 1 }], + modified: [{ layer: "l3", id: "old-block", fromRev: 1, toRev: 3 }], + unchanged: [{ layer: "l5", id: "proj", rev: 2 }], + }); + + expect(output).toContain("Created (1):"); + expect(output).toContain("+ l3/new-block"); + expect(output).toContain("Modified (1):"); + expect(output).toContain("~ l3/old-block"); + expect(output).toContain("Unchanged (1):"); + expect(output).toContain(". l5/proj"); + }); + + it("shows 'No artifacts' for empty diff", () => { + const output = formatDiffSummary({ created: [], modified: [], unchanged: [] }); + expect(output).toContain("No artifacts in scope."); + }); +}); + +// ── Store: changeset CRUD ── + +const makeChangeset = (id: string, status: "active" | "completed" = "active"): Changeset => ({ + id, + name: id, + reason: "test reason", + status, + baseline: { "l5:proj": 1, "l3:block": 2 }, + createdAt: "2024-06-01T00:00:00.000Z", + ...(status === "completed" ? { completedAt: "2024-06-02T00:00:00.000Z" } : {}), +}); + +describe("store changeset", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-changeset-")); + await mkdir(path.join(root, ".svp", "changesets"), { recursive: true }); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("write then read roundtrip", async () => { + const cs = makeChangeset("test-cs"); + await writeChangeset(root, cs); + const loaded = await readChangeset(root, "test-cs"); + expect(loaded).toEqual(cs); + }); + + it("read non-existent returns null", async () => { + const result = await readChangeset(root, "nope"); + expect(result).toBeNull(); + }); + + it("list returns written ids", async () => { + await writeChangeset(root, makeChangeset("cs-a")); + await writeChangeset(root, makeChangeset("cs-b")); + const ids = await listChangesets(root); + expect(ids).toContain("cs-a"); + expect(ids).toContain("cs-b"); + }); + + it("delete removes changeset", async () => { + await writeChangeset(root, makeChangeset("cs-del")); + await deleteChangeset(root, "cs-del"); + const loaded = await readChangeset(root, "cs-del"); + expect(loaded).toBeNull(); + }); + + it("delete non-existent is a no-op", async () => { + await expect(deleteChangeset(root, "no-such-cs")).resolves.toBeUndefined(); + }); + + it("findActiveChangeset returns active", async () => { + const isoRoot = await mkdtemp(path.join(tmpdir(), "svp-cs-find-")); + try { + await mkdir(path.join(isoRoot, ".svp", "changesets"), { recursive: true }); + await writeChangeset(isoRoot, makeChangeset("cs-completed", "completed")); + await writeChangeset(isoRoot, makeChangeset("cs-active", "active")); + const active = await findActiveChangeset(isoRoot); + expect(active).not.toBeNull(); + expect(active!.id).toBe("cs-active"); + } finally { + await rm(isoRoot, { recursive: true }); + } + }); + + it("findActiveChangeset returns null when none active", async () => { + const isolatedRoot = await mkdtemp(path.join(tmpdir(), "svp-cs-none-")); + try { + await mkdir(path.join(isolatedRoot, ".svp", "changesets"), { recursive: true }); + await writeChangeset(isolatedRoot, makeChangeset("done-cs", "completed")); + const active = await findActiveChangeset(isolatedRoot); + expect(active).toBeNull(); + } finally { + await rm(isolatedRoot, { recursive: true }); + } + }); + + it("findActiveChangeset returns null on empty dir", async () => { + const emptyRoot = await mkdtemp(path.join(tmpdir(), "svp-cs-empty-")); + try { + const active = await findActiveChangeset(emptyRoot); + expect(active).toBeNull(); + } finally { + await rm(emptyRoot, { recursive: true }); + } + }); +}); diff --git a/packages/core/changeset.ts b/packages/core/changeset.ts new file mode 100644 index 0000000..5094d60 --- /dev/null +++ b/packages/core/changeset.ts @@ -0,0 +1,113 @@ +// Changeset — cross-artifact version grouping +// Records a baseline snapshot, computes diff at view/complete time + +import type { CheckInput } from "./check.js"; + +/** A changeset groups related cross-artifact changes */ +export interface Changeset { + readonly id: string; // kebab-case, e.g. "add-stockout-notification" + readonly name: string; // human-readable + readonly reason: string; // why this change is being made + readonly status: "active" | "completed"; + readonly baseline: Record; // "l5:project-id" → rev, "l3:validate-order" → 2 + readonly createdAt: string; // ISO 8601 + readonly completedAt?: string; +} + +/** Computed diff between baseline and current state */ +export interface ChangesetDiff { + readonly created: Array<{ layer: string; id: string; currentRev: number }>; + readonly modified: Array<{ layer: string; id: string; fromRev: number; toRev: number }>; + readonly unchanged: Array<{ layer: string; id: string; rev: number }>; +} + +/** Build a baseline snapshot from current artifact revisions */ +export function computeBaselineFromArtifacts(input: CheckInput): Record { + const baseline: Record = {}; + + if (input.l5 !== undefined) { + baseline[`l5:${input.l5.id}`] = input.l5.revision.rev; + } + + for (const l4 of input.l4Flows) { + baseline[`l4:${l4.id}`] = l4.revision.rev; + } + + for (const l3 of input.l3Blocks) { + baseline[`l3:${l3.id}`] = l3.revision.rev; + } + + for (const l2 of input.l2Blocks) { + baseline[`l2:${l2.id}`] = l2.revision.rev; + } + + return baseline; +} + +/** Compute diff between baseline revs and current revs */ +export function computeDiff( + baseline: Record, + current: Record, +): ChangesetDiff { + const created: ChangesetDiff["created"] = []; + const modified: ChangesetDiff["modified"] = []; + const unchanged: ChangesetDiff["unchanged"] = []; + + // Check current artifacts against baseline + for (const [key, currentRev] of Object.entries(current)) { + const [layer, id] = splitKey(key); + + if (!(key in baseline)) { + created.push({ layer, id, currentRev }); + } else if (currentRev > baseline[key]) { + modified.push({ layer, id, fromRev: baseline[key], toRev: currentRev }); + } else { + unchanged.push({ layer, id, rev: currentRev }); + } + } + + // Baseline artifacts still present in current are already handled above. + // Baseline artifacts NOT in current would be "deleted", but we don't track that. + // (Deletion is rare in SVP and is git's domain.) + + return { created, modified, unchanged }; +} + +/** Format a diff summary for human-readable output */ +export function formatDiffSummary(diff: ChangesetDiff): string { + const lines: string[] = []; + + if (diff.created.length > 0) { + lines.push(`Created (${String(diff.created.length)}):`); + for (const c of diff.created) { + lines.push(` + ${c.layer}/${c.id} (rev ${String(c.currentRev)})`); + } + } + + if (diff.modified.length > 0) { + lines.push(`Modified (${String(diff.modified.length)}):`); + for (const m of diff.modified) { + lines.push(` ~ ${m.layer}/${m.id} (rev ${String(m.fromRev)} → ${String(m.toRev)})`); + } + } + + if (diff.unchanged.length > 0) { + lines.push(`Unchanged (${String(diff.unchanged.length)}):`); + for (const u of diff.unchanged) { + lines.push(` . ${u.layer}/${u.id} (rev ${String(u.rev)})`); + } + } + + if (lines.length === 0) { + lines.push("No artifacts in scope."); + } + + return lines.join("\n"); +} + +/** Split a baseline key like "l3:validate-order" into [layer, id] */ +function splitKey(key: string): [string, string] { + const idx = key.indexOf(":"); + if (idx === -1) return ["unknown", key]; + return [key.slice(0, idx), key.slice(idx + 1)]; +} diff --git a/packages/core/check.ts b/packages/core/check.ts index 84c3ceb..c6952a7 100644 --- a/packages/core/check.ts +++ b/packages/core/check.ts @@ -42,6 +42,10 @@ export interface CheckInput { // 由调用方(CLI)提前计算,check 只做比对,不依赖提取器 // 省略时跳过 CONTENT_DRIFT 检测 readonly l1SignatureHashes?: ReadonlyMap; + + // 已有文档的 L3 block id 集合(nodes//docs.md 存在的) + // 省略时跳过 MISSING_NODE_DOCS 检测 + readonly existingNodeDocs?: ReadonlySet; } // ── 主入口 ── @@ -53,6 +57,7 @@ export function check(input: CheckInput, language = "en"): CheckReport { ...checkReferentialIntegrity(input, lang), ...checkDrift(input, lang), ...checkGraphStructure(input, lang), + ...checkDocsPresence(input, lang), ]; return { @@ -899,3 +904,28 @@ function detectOrphanSteps( return issues; } + +// ── 5. 文档存在性检查 ── + +function checkDocsPresence(input: CheckInput, lang: string): CheckIssue[] { + const issues: CheckIssue[] = []; + + if (input.existingNodeDocs === undefined) return issues; + + // 找出有 L2 映射的 L3 blocks(即已有实现的模块) + const l3WithL2 = new Set(input.l2Blocks.map((cb) => cb.blockRef)); + + for (const blockRef of l3WithL2) { + if (!input.existingNodeDocs.has(blockRef)) { + issues.push({ + severity: "warning", + layer: "l3", + entityId: blockRef, + code: "MISSING_NODE_DOCS", + message: t(lang, "check.missingNodeDocs", { blockRef }), + }); + } + } + + return issues; +} diff --git a/packages/core/docs.test.ts b/packages/core/docs.test.ts new file mode 100644 index 0000000..e836261 --- /dev/null +++ b/packages/core/docs.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { checkDocs } from "./docs.js"; +import type { DocsCheckInput } from "./docs.js"; +import type { L3Block } from "./l3.js"; +import type { L4Flow } from "./l4.js"; +import type { L5Blueprint } from "./l5.js"; +import type { ArtifactVersion } from "./version.js"; + +const REV: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00.000Z", +}; + +const makeL5 = (id: string): L5Blueprint => ({ + id, + name: "Test", + version: "1.0.0", + intent: "test", + constraints: [], + domains: [], + integrations: [], + revision: REV, + contentHash: "abc", +}); + +const makeL3 = (id: string): L3Block => ({ + id, + name: "Test Block", + input: [{ name: "req", type: "Request" }], + output: [{ name: "res", type: "Response" }], + validate: {}, + constraints: [], + description: "test", + revision: REV, + contentHash: "abc", +}); + +const makeL4 = (id: string): L4Flow => ({ + id, + name: "Test Flow", + steps: [{ id: "s1", action: "process", blockRef: "b1", next: null }], + dataFlows: [], + revision: REV, + contentHash: "def", +}); + +describe("checkDocs", () => { + it("returns no issues when all docs exist", () => { + const input: DocsCheckInput = { + l5: makeL5("my-project"), + l4Flows: [makeL4("flow-a")], + l3Blocks: [makeL3("block-a")], + l2Blocks: [], + existingDocs: { + l5: true, + nodes: new Map([["block-a", true]]), + graphs: new Map([["flow-a", true]]), + }, + }; + const issues = checkDocs(input); + expect(issues).toEqual([]); + }); + + it("reports missing L5 docs", () => { + const input: DocsCheckInput = { + l5: makeL5("my-project"), + l4Flows: [], + l3Blocks: [], + l2Blocks: [], + existingDocs: { l5: false, nodes: new Map(), graphs: new Map() }, + }; + const issues = checkDocs(input); + expect(issues).toHaveLength(1); + expect(issues[0].layer).toBe("l5"); + expect(issues[0].code).toBe("MISSING_DOCS"); + }); + + it("does not report L5 docs when no L5 exists", () => { + const input: DocsCheckInput = { + l5: undefined, + l4Flows: [], + l3Blocks: [], + l2Blocks: [], + existingDocs: { l5: false, nodes: new Map(), graphs: new Map() }, + }; + const issues = checkDocs(input); + expect(issues).toEqual([]); + }); + + it("reports missing L3 node docs", () => { + const input: DocsCheckInput = { + l5: undefined, + l4Flows: [], + l3Blocks: [makeL3("block-a"), makeL3("block-b")], + l2Blocks: [], + existingDocs: { + l5: false, + nodes: new Map([["block-a", true]]), + graphs: new Map(), + }, + }; + const issues = checkDocs(input); + expect(issues).toHaveLength(1); + expect(issues[0].entityId).toBe("block-b"); + expect(issues[0].layer).toBe("l3"); + }); + + it("reports missing L4 graph docs", () => { + const input: DocsCheckInput = { + l5: undefined, + l4Flows: [makeL4("flow-a"), makeL4("flow-b")], + l3Blocks: [], + l2Blocks: [], + existingDocs: { + l5: false, + nodes: new Map(), + graphs: new Map([["flow-b", true]]), + }, + }; + const issues = checkDocs(input); + expect(issues).toHaveLength(1); + expect(issues[0].entityId).toBe("flow-a"); + expect(issues[0].layer).toBe("l4"); + }); + + it("reports all missing docs across layers", () => { + const input: DocsCheckInput = { + l5: makeL5("proj"), + l4Flows: [makeL4("flow-a")], + l3Blocks: [makeL3("block-a")], + l2Blocks: [], + existingDocs: { l5: false, nodes: new Map(), graphs: new Map() }, + }; + const issues = checkDocs(input); + expect(issues).toHaveLength(3); + for (const issue of issues) { + expect(issue.severity).toBe("warning"); + expect(issue.code).toBe("MISSING_DOCS"); + } + }); +}); diff --git a/packages/core/docs.ts b/packages/core/docs.ts new file mode 100644 index 0000000..bf0ad3b --- /dev/null +++ b/packages/core/docs.ts @@ -0,0 +1,70 @@ +// 文档检查核心逻辑 — 纯函数,不做 IO + +import type { L2CodeBlock } from "./l2.js"; +import type { L3Block } from "./l3.js"; +import type { L4Artifact } from "./l4.js"; +import type { L5Blueprint } from "./l5.js"; + +export interface DocsCheckInput { + readonly l5?: L5Blueprint; + readonly l4Flows: readonly L4Artifact[]; + readonly l3Blocks: readonly L3Block[]; + readonly l2Blocks: readonly L2CodeBlock[]; + readonly existingDocs: { + readonly l5: boolean; + readonly nodes: ReadonlyMap; // nodeId → has docs.md + readonly graphs: ReadonlyMap; // graphName → has docs.md + }; +} + +export interface DocsIssue { + readonly severity: "warning"; + readonly layer: string; + readonly entityId: string; + readonly code: "MISSING_DOCS"; + readonly message: string; +} + +/** 检查文档覆盖率,返回缺失文档的 warning 列表 */ +export function checkDocs(input: DocsCheckInput): DocsIssue[] { + const issues: DocsIssue[] = []; + + // L5 blueprint → docs/l5.md + if (input.l5 !== undefined && !input.existingDocs.l5) { + issues.push({ + severity: "warning", + layer: "l5", + entityId: input.l5.id, + code: "MISSING_DOCS", + message: `L5 blueprint "${input.l5.id}" has no project documentation (docs/l5.md)`, + }); + } + + // L3 blocks → nodes//docs.md + for (const block of input.l3Blocks) { + if (!input.existingDocs.nodes.has(block.id)) { + issues.push({ + severity: "warning", + layer: "l3", + entityId: block.id, + code: "MISSING_DOCS", + message: `L3 block "${block.id}" has no module documentation (nodes/${block.id}/docs.md)`, + }); + } + } + + // L4 artifacts → graphs/.docs.md + for (const artifact of input.l4Flows) { + if (!input.existingDocs.graphs.has(artifact.id)) { + issues.push({ + severity: "warning", + layer: "l4", + entityId: artifact.id, + code: "MISSING_DOCS", + message: `L4 artifact "${artifact.id}" has no graph documentation (graphs/${artifact.id}.docs.md)`, + }); + } + } + + return issues; +} diff --git a/packages/core/i18n.ts b/packages/core/i18n.ts index aea8007..16d40d6 100644 --- a/packages/core/i18n.ts +++ b/packages/core/i18n.ts @@ -113,6 +113,8 @@ const messages: Record> = { "check.orphanStep": 'L4 "{entityName}" step "{stepId}" is not reachable from the first step', "check.missingLanguage": "L5 blueprint has no language field — consider adding language preference", + "check.missingNodeDocs": + 'L3 block "{blockRef}" has implementation but no documentation (nodes/{blockRef}/docs.md)', // ── compilePlan.* ── "compilePlan.reason.missingL2": @@ -253,6 +255,7 @@ const messages: Record> = { "check.nextCycle": 'L4 "{entityName}" 在 next 链中存在环,涉及步骤 "{current}"', "check.orphanStep": 'L4 "{entityName}" 步骤 "{stepId}" 从第一个步骤不可达', "check.missingLanguage": "L5 blueprint 未设置 language 字段 — 建议添加语言偏好", + "check.missingNodeDocs": 'L3 block "{blockRef}" 有实现但缺少文档 (nodes/{blockRef}/docs.md)', // ── compilePlan.* ── "compilePlan.reason.missingL2": 'L3 block "{name}" 没有对应的 L2 code block — 需要初始编译', diff --git a/packages/core/index.ts b/packages/core/index.ts index ae898f5..a94ef05 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -27,6 +27,7 @@ export { collectFlowRefs, resolveDataFlowType, } from "./computed.js"; +export type { RefFile } from "./store.js"; export { readL3, writeL3, @@ -41,9 +42,15 @@ export { listL2, readNodeDocs, readGraphDocs, + readL5Docs, + readL2Docs, + readNodeRefs, + readGraphRefs, } from "./store.js"; export type { CheckIssue, CheckReport, CheckInput, IssueSeverity } from "./check.js"; export { check } from "./check.js"; +export type { DocsCheckInput, DocsIssue } from "./docs.js"; +export { checkDocs } from "./docs.js"; export type { CompileTask, CompilePlan, @@ -85,3 +92,25 @@ 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"; +export type { ScanOptions, ScannedFile, ScanContext } from "./scan.js"; +export { collectScanContext } from "./scan.js"; +export type { Changeset, ChangesetDiff } from "./changeset.js"; +export { computeBaselineFromArtifacts, computeDiff, formatDiffSummary } from "./changeset.js"; +export { + writeChangeset, + readChangeset, + listChangesets, + deleteChangeset, + findActiveChangeset, +} from "./store.js"; diff --git a/packages/core/init.ts b/packages/core/init.ts index c912ecb..326f6eb 100644 --- a/packages/core/init.ts +++ b/packages/core/init.ts @@ -5,11 +5,12 @@ 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"; const SVP_DIR = ".svp"; -const SUB_DIRS = ["l2", "l3", "l4"] as const; +const SUB_DIRS = ["l2", "l3", "l4", "changesets"] as const; export interface InitOptions { readonly name: string; @@ -49,6 +50,10 @@ export async function init(root: string, options: InitOptions): Promise = { id: slugify(options.name), @@ -74,6 +79,10 @@ export async function init(root: string, options: InitOptions): Promise { + testRoot = path.join( + import.meta.dirname, + `__test_manifest_${String(Date.now())}_${Math.random().toString(36).slice(2)}`, + ); + 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"); + }); +}); diff --git a/packages/core/manifest.ts b/packages/core/manifest.ts new file mode 100644 index 0000000..5095ca6 --- /dev/null +++ b/packages/core/manifest.ts @@ -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.1.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; +} + +/** Read manifest.json, returns null if missing */ +export async function readManifest(root: string): Promise { + try { + const content = await readFile(manifestPath(root), "utf8"); + return JSON.parse(content) as Manifest; + } catch { + return null; + } +} + +/** Write manifest.json */ +export async function writeManifest(root: string, manifest: Manifest): Promise { + await writeFile(manifestPath(root), JSON.stringify(manifest, null, 2) + "\n", "utf8"); +} + +/** 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 { + // 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; +} diff --git a/packages/core/migrate.test.ts b/packages/core/migrate.test.ts new file mode 100644 index 0000000..ec0e2d7 --- /dev/null +++ b/packages/core/migrate.test.ts @@ -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"); + }); +}); diff --git a/packages/core/migrate.ts b/packages/core/migrate.ts new file mode 100644 index 0000000..99c0001 --- /dev/null +++ b/packages/core/migrate.ts @@ -0,0 +1,32 @@ +// Migration runner — sequential schema migrations for .svp/ data model + +import { migrations } from "./migrations/index.js"; + +export interface Migration { + readonly from: string; // major version, e.g. "1" + readonly to: string; // major version, e.g. "2" + readonly migrate: (root: string) => Promise; +} + +/** + * Run all migrations needed to go from `fromMajor` to `toMajor`. + * Finds the chain of migrations and executes them sequentially. + */ +export async function runMigrations( + root: string, + fromMajor: number, + toMajor: number, +): Promise { + for (let v = fromMajor; v < toMajor; v++) { + const from = String(v); + const to = String(v + 1); + const migration = migrations.find((m) => m.from === from && m.to === to); + if (migration === undefined) { + throw new Error( + `No migration found from schema v${from} to v${to}. ` + + `Cannot upgrade .svp/ data. Please upgrade forge to a version that supports this migration.`, + ); + } + await migration.migrate(root); + } +} diff --git a/packages/core/migrations/index.ts b/packages/core/migrations/index.ts new file mode 100644 index 0000000..d9ae612 --- /dev/null +++ b/packages/core/migrations/index.ts @@ -0,0 +1,9 @@ +// Migration registry — export all migrations here +// Future migrations go in separate files, e.g. v1-to-v2.ts + +import type { Migration } from "../migrate.js"; + +export const migrations: readonly Migration[] = [ + // Example for future use: + // { from: "1", to: "2", migrate: v1ToV2 }, +]; diff --git a/packages/core/scan.test.ts b/packages/core/scan.test.ts new file mode 100644 index 0000000..173d271 --- /dev/null +++ b/packages/core/scan.test.ts @@ -0,0 +1,178 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectScanContext } from "./scan.js"; +import type { SignatureExtractor } from "./fingerprint.js"; + +// ── Helpers ── + +let tempDir: string; + +async function makeTempProject(files: Record): Promise { + tempDir = path.join( + tmpdir(), + `svp-scan-test-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`, + ); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(tempDir, filePath); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, "utf8"); + } + return tempDir; +} + +// Stub extractor that returns exports based on file content +const stubExtractor: SignatureExtractor = { + extract: (filePath: string) => + Promise.resolve({ + filePath, + exports: [{ name: "stubExport", kind: "function" as const, signature: "() => void" }], + }), +}; + +afterEach(async () => { + const { rm } = await import("node:fs/promises"); + await rm(tempDir, { recursive: true, force: true }).catch((error: unknown) => error); +}); + +// ── Tests ── + +describe("collectScanContext", () => { + it("collects .ts files with exports via extractor", async () => { + const root = await makeTempProject({ + "src/index.ts": "export function hello() {}", + "src/utils.ts": "export const FOO = 1;", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }, stubExtractor); + + expect(result.files).toHaveLength(2); + expect(result.summary.totalFiles).toBe(2); + expect(result.summary.totalExports).toBe(2); + expect(result.summary.truncated).toBe(false); + + // Both files should have stub exports + for (const f of result.files) { + expect(f.exports).toHaveLength(1); + expect(f.exports[0].name).toBe("stubExport"); + } + }); + + it("includes non-.ts files with empty exports for directory awareness", async () => { + const root = await makeTempProject({ + "src/config.json": "{}", + "src/readme.md": "# Hello", + "src/index.ts": "export const x = 1;", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }, stubExtractor); + + expect(result.files).toHaveLength(3); + + const jsonFile = result.files.find((f) => f.filePath.endsWith(".json")); + expect(jsonFile).toBeDefined(); + expect(jsonFile!.exports).toHaveLength(0); + + const tsFile = result.files.find((f) => f.filePath.endsWith(".ts")); + expect(tsFile).toBeDefined(); + expect(tsFile!.exports).toHaveLength(1); + }); + + it("respects maxFiles cap and sets truncated flag", async () => { + const files: Record = {}; + for (let i = 0; i < 10; i++) { + files[`src/file${String(i).padStart(2, "0")}.ts`] = + `export const x${String(i)} = ${String(i)};`; + } + const root = await makeTempProject(files); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 3 }, stubExtractor); + + expect(result.files).toHaveLength(3); + expect(result.summary.totalFiles).toBe(3); + expect(result.summary.truncated).toBe(true); + }); + + it("excludes node_modules, dist, build directories", async () => { + const root = await makeTempProject({ + "src/index.ts": "export const a = 1;", + "src/node_modules/dep/index.ts": "export const b = 2;", + "src/dist/bundle.js": "var x = 1;", + "src/build/output.js": "var y = 2;", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }, stubExtractor); + + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).toContain("index.ts"); + }); + + it("excludes test and spec files", async () => { + const root = await makeTempProject({ + "src/handler.ts": "export function handle() {}", + "src/handler.test.ts": "test('it works', () => {});", + "src/handler.spec.ts": "describe('handler', () => {});", + "src/utils.test.tsx": "test('renders', () => {});", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }, stubExtractor); + + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).toContain("handler.ts"); + expect(result.files[0].filePath).not.toContain("test"); + }); + + it("excludes .d.ts files", async () => { + const root = await makeTempProject({ + "src/index.ts": "export const a = 1;", + "src/types.d.ts": "declare module 'foo' {}", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }, stubExtractor); + + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).not.toContain(".d.ts"); + }); + + it("returns empty when dir does not exist", async () => { + const root = await makeTempProject({}); + + const result = await collectScanContext( + { root, dir: "nonexistent", maxFiles: 50 }, + stubExtractor, + ); + + expect(result.files).toHaveLength(0); + expect(result.summary.totalFiles).toBe(0); + expect(result.summary.totalExports).toBe(0); + }); + + it("works without extractor — all files get empty exports", async () => { + const root = await makeTempProject({ + "src/index.ts": "export const a = 1;", + "src/utils.ts": "export const b = 2;", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }); + + expect(result.files).toHaveLength(2); + expect(result.summary.totalExports).toBe(0); + for (const f of result.files) { + expect(f.exports).toHaveLength(0); + } + }); + + it("sorts files by path", async () => { + const root = await makeTempProject({ + "src/z.ts": "", + "src/a.ts": "", + "src/m.ts": "", + }); + + const result = await collectScanContext({ root, dir: "src", maxFiles: 50 }, stubExtractor); + + const paths = result.files.map((f) => f.filePath); + expect(paths).toEqual([...paths].toSorted()); + }); +}); diff --git a/packages/core/scan.ts b/packages/core/scan.ts new file mode 100644 index 0000000..92bcf37 --- /dev/null +++ b/packages/core/scan.ts @@ -0,0 +1,138 @@ +// scan — Brownfield reverse generation context collector +// Walks existing codebase, extracts TS signatures, builds structured context +// for AI prompts that reverse-engineer SVP artifacts from existing code + +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import type { ExportedSymbol, FileFingerprint, SignatureExtractor } from "./fingerprint.js"; + +// ── Types ── + +export interface ScanOptions { + readonly root: string; + readonly dir: string; // relative to root, default "src" or "." + readonly maxFiles: number; // default 50 +} + +export interface ScannedFile { + readonly filePath: string; // relative to root + readonly exports: readonly ExportedSymbol[]; +} + +export interface ScanContext { + readonly files: readonly ScannedFile[]; + readonly summary: { + readonly totalFiles: number; + readonly totalExports: number; + readonly truncated: boolean; + }; +} + +// ── Auto-exclude patterns ── + +const EXCLUDE_DIRS = new Set([ + "node_modules", + "dist", + "build", + ".svp", + ".git", + ".next", + "coverage", + "__pycache__", +]); + +const EXCLUDE_FILE_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.[jt]s$/]; + +function shouldExcludeDir(name: string): boolean { + return EXCLUDE_DIRS.has(name); +} + +function shouldExcludeFile(name: string): boolean { + return EXCLUDE_FILE_PATTERNS.some((re) => re.test(name)); +} + +function isTypeScriptFile(name: string): boolean { + return /\.[jt]sx?$/.test(name) && !name.endsWith(".d.ts"); +} + +// ── Recursive file walker ── + +async function walkDir(dir: string, root: string): Promise { + const result: string[] = []; + + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return result; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + let s; + try { + s = await stat(fullPath); + } catch { + continue; + } + + if (s.isDirectory()) { + if (!shouldExcludeDir(entry)) { + const children = await walkDir(fullPath, root); + result.push(...children); + } + } else if (s.isFile() && !shouldExcludeFile(entry)) { + result.push(path.relative(root, fullPath)); + } + } + + return result; +} + +// ── Main collector ── + +/** Collect scan context from an existing codebase for reverse generation prompts */ +export async function collectScanContext( + options: ScanOptions, + extractor?: SignatureExtractor, +): Promise { + const { root, dir, maxFiles } = options; + const scanDir = path.resolve(root, dir); + + // Walk and collect all non-excluded files + const allFiles = await walkDir(scanDir, root); + allFiles.sort((a, b) => a.localeCompare(b)); + + const truncated = allFiles.length > maxFiles; + const filesToProcess = allFiles.slice(0, maxFiles); + + const scannedFiles: ScannedFile[] = []; + let totalExports = 0; + + for (const filePath of filesToProcess) { + if (isTypeScriptFile(filePath) && extractor !== undefined) { + // Extract TS signatures + const absPath = path.resolve(root, filePath); + try { + const fp: FileFingerprint = await extractor.extract(absPath); + scannedFiles.push({ filePath, exports: fp.exports }); + totalExports += fp.exports.length; + } catch { + // If extraction fails, include path only + scannedFiles.push({ filePath, exports: [] }); + } + } else { + // Non-TS files: include path only for directory structure awareness + scannedFiles.push({ filePath, exports: [] }); + } + } + + return { + files: scannedFiles, + summary: { + totalFiles: scannedFiles.length, + totalExports, + truncated, + }, + }; +} diff --git a/packages/core/skill.ts b/packages/core/skill.ts index 4554aad..0b98d5c 100644 --- a/packages/core/skill.ts +++ b/packages/core/skill.ts @@ -7,6 +7,7 @@ import type { L2CodeBlock } from "./l2.js"; import type { L3Block } from "./l3.js"; import type { L4Artifact } from "./l4.js"; import type { L5Blueprint } from "./l5.js"; +import type { RefFile } from "./store.js"; import type { ArtifactVersion } from "./version.js"; // ── Skill 输入 ── @@ -19,6 +20,7 @@ export interface ResolvedContext { readonly l4?: L4Artifact; readonly l1Files?: readonly FileContent[]; readonly docs?: string; + readonly refs?: readonly RefFile[]; } /** L1 源文件内容 */ diff --git a/packages/core/store.test.ts b/packages/core/store.test.ts index 76fd4e7..ea3615c 100644 --- a/packages/core/store.test.ts +++ b/packages/core/store.test.ts @@ -12,6 +12,10 @@ import { readL5, readNodeDocs, readGraphDocs, + readL5Docs, + readL2Docs, + readNodeRefs, + readGraphRefs, writeL2, writeL3, writeL4, @@ -624,3 +628,163 @@ describe("readGraphDocs", () => { expect(result).toBeNull(); }); }); + +// ── L5 Docs ── + +describe("readL5Docs", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-docs-l5-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("reads existing docs/l5.md", async () => { + const docsDir = path.join(root, "docs"); + await mkdir(docsDir, { recursive: true }); + await writeFile(path.join(docsDir, "l5.md"), "# Architecture\nGlobal constraints", "utf8"); + const result = await readL5Docs(root); + expect(result).toBe("# Architecture\nGlobal constraints"); + }); + + it("returns null when docs/l5.md does not exist", async () => { + const freshRoot = await mkdtemp(path.join(tmpdir(), "svp-docs-l5-empty-")); + try { + const result = await readL5Docs(freshRoot); + expect(result).toBeNull(); + } finally { + await rm(freshRoot, { recursive: true }); + } + }); +}); + +// ── L2 Docs ── + +describe("readL2Docs", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-docs-l2-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("reads existing impl.docs.md", async () => { + const nodeDir = path.join(root, "nodes", "my-block"); + await mkdir(nodeDir, { recursive: true }); + await writeFile(path.join(nodeDir, "impl.docs.md"), "# Deploy Notes\nPerformance tips", "utf8"); + const result = await readL2Docs(root, "my-block"); + expect(result).toBe("# Deploy Notes\nPerformance tips"); + }); + + it("returns null when impl.docs.md does not exist", async () => { + const result = await readL2Docs(root, "nonexistent-block"); + expect(result).toBeNull(); + }); +}); + +// ── Refs ── + +describe("readNodeRefs", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-refs-node-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("returns text file with content", async () => { + const refsDir = path.join(root, "nodes", "my-block", "refs"); + await mkdir(refsDir, { recursive: true }); + await writeFile(path.join(refsDir, "algorithm.md"), "# Luhn Check\nUse mod 10", "utf8"); + + const refs = await readNodeRefs(root, "my-block"); + expect(refs).toHaveLength(1); + expect(refs[0].name).toBe("algorithm.md"); + expect(refs[0].path).toBe(path.join("nodes", "my-block", "refs", "algorithm.md")); + expect(refs[0].isText).toBe(true); + expect(refs[0].content).toBe("# Luhn Check\nUse mod 10"); + }); + + it("returns binary file with path only (no content)", async () => { + const refsDir = path.join(root, "nodes", "img-block", "refs"); + await mkdir(refsDir, { recursive: true }); + await writeFile(path.join(refsDir, "design.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const refs = await readNodeRefs(root, "img-block"); + expect(refs).toHaveLength(1); + expect(refs[0].name).toBe("design.png"); + expect(refs[0].isText).toBe(false); + expect(refs[0].content).toBeUndefined(); + }); + + it("returns empty array for missing directory", async () => { + const refs = await readNodeRefs(root, "nonexistent-block"); + expect(refs).toEqual([]); + }); + + it("sorts files by name", async () => { + const refsDir = path.join(root, "nodes", "sorted-block", "refs"); + await mkdir(refsDir, { recursive: true }); + await writeFile(path.join(refsDir, "zebra.md"), "z", "utf8"); + await writeFile(path.join(refsDir, "alpha.txt"), "a", "utf8"); + await writeFile(path.join(refsDir, "middle.ts"), "m", "utf8"); + + const refs = await readNodeRefs(root, "sorted-block"); + expect(refs.map((r) => r.name)).toEqual(["alpha.txt", "middle.ts", "zebra.md"]); + }); + + it("handles mixed text and binary files", async () => { + const refsDir = path.join(root, "nodes", "mixed-block", "refs"); + await mkdir(refsDir, { recursive: true }); + await writeFile(path.join(refsDir, "spec.md"), "# Spec", "utf8"); + await writeFile(path.join(refsDir, "mockup.fig"), Buffer.from([0x00])); + await writeFile(path.join(refsDir, "ref.ts"), "export const x = 1;", "utf8"); + + const refs = await readNodeRefs(root, "mixed-block"); + expect(refs).toHaveLength(3); + + const textRefs = refs.filter((r) => r.isText); + const binaryRefs = refs.filter((r) => !r.isText); + expect(textRefs).toHaveLength(2); + expect(binaryRefs).toHaveLength(1); + expect(binaryRefs[0].name).toBe("mockup.fig"); + }); +}); + +describe("readGraphRefs", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-refs-graph-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("reads refs from graphs//refs/", async () => { + const refsDir = path.join(root, "graphs", "order-flow", "refs"); + await mkdir(refsDir, { recursive: true }); + await writeFile(path.join(refsDir, "flow-notes.md"), "# Notes", "utf8"); + + const refs = await readGraphRefs(root, "order-flow"); + expect(refs).toHaveLength(1); + expect(refs[0].name).toBe("flow-notes.md"); + expect(refs[0].isText).toBe(true); + expect(refs[0].content).toBe("# Notes"); + }); + + it("returns empty array for missing graph refs", async () => { + const refs = await readGraphRefs(root, "no-such-graph"); + expect(refs).toEqual([]); + }); +}); diff --git a/packages/core/store.ts b/packages/core/store.ts index fbce091..843d1c6 100644 --- a/packages/core/store.ts +++ b/packages/core/store.ts @@ -1,8 +1,9 @@ // 读写层 — .svp/ 目录下的 JSON 文件 // 运行时是 TS 对象,持久化用 JSON -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Changeset } from "./changeset.js"; import type { L2CodeBlock } from "./l2.js"; import type { L3Block } from "./l3.js"; import type { L4Artifact } from "./l4.js"; @@ -113,6 +114,78 @@ export async function listL2(root: string): Promise { return listIds(svpPath(root, "l2")); } +// ── Refs ── + +/** A reference file attached to a block's refs/ folder */ +export interface RefFile { + readonly name: string; // "design.png" + readonly path: string; // "nodes/date-picker/refs/design.png" + readonly isText: boolean; // true for .md/.txt/.ts/.js etc. + readonly content?: string; // text content (only for text files) +} + +const TEXT_EXTENSIONS = new Set([ + ".md", + ".txt", + ".ts", + ".tsx", + ".js", + ".jsx", + ".py", + ".go", + ".rs", + ".java", + ".json", + ".yaml", + ".yml", + ".toml", + ".css", + ".html", + ".sql", +]); + +function isTextFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return TEXT_EXTENSIONS.has(ext); +} + +/** Read reference files from nodes//refs/, returns [] if missing */ +export async function readNodeRefs(root: string, nodeId: string): Promise { + return readRefsDir(root, path.join("nodes", nodeId, "refs")); +} + +/** Read reference files from graphs//refs/, returns [] if missing */ +export async function readGraphRefs(root: string, graphId: string): Promise { + return readRefsDir(root, path.join("graphs", graphId, "refs")); +} + +async function readRefsDir(root: string, relDir: string): Promise { + const absDir = path.join(root, relDir); + let entries: string[]; + try { + entries = await readdir(absDir); + } catch { + return []; + } + + const refs: RefFile[] = []; + for (const name of entries.toSorted()) { + const filePath = path.join(relDir, name); + const text = isTextFile(name); + const ref: RefFile = { name, path: filePath, isText: text }; + if (text) { + try { + const content = await readFile(path.join(root, filePath), "utf8"); + (ref as { content: string }).content = content; + } catch { + // skip unreadable files + } + } + refs.push(ref); + } + return refs; +} + // ── Docs ── /** 读取节点的模块化文档 nodes//docs.md,不存在返回 null */ @@ -132,3 +205,62 @@ export async function readGraphDocs(root: string, graphName: string): Promise { + try { + return await readFile(path.join(root, "docs", "l5.md"), "utf8"); + } catch { + return null; + } +} + +/** 读取实现级文档 nodes//impl.docs.md(部署注意事项、性能说明),不存在返回 null */ +export async function readL2Docs(root: string, l2Id: string): Promise { + try { + return await readFile(path.join(root, "nodes", l2Id, "impl.docs.md"), "utf8"); + } catch { + return null; + } +} + +// ── Changesets ── + +/** Write a changeset JSON file */ +export async function writeChangeset(root: string, cs: Changeset): Promise { + await ensureDir(svpPath(root, "changesets")); + await writeJson(svpPath(root, "changesets", `${cs.id}.json`), cs); +} + +/** Read a changeset by id, returns null if missing */ +export async function readChangeset(root: string, id: string): Promise { + try { + return await readJson(svpPath(root, "changesets", `${id}.json`)); + } catch { + return null; + } +} + +/** List all changeset ids */ +export async function listChangesets(root: string): Promise { + return listIds(svpPath(root, "changesets")); +} + +/** Delete a changeset file */ +export async function deleteChangeset(root: string, id: string): Promise { + try { + await unlink(svpPath(root, "changesets", `${id}.json`)); + } catch { + // file doesn't exist — no-op + } +} + +/** Find the currently active changeset, or null if none */ +export async function findActiveChangeset(root: string): Promise { + const ids = await listChangesets(root); + for (const id of ids) { + const cs = await readChangeset(root, id); + if (cs !== null && cs.status === "active") return cs; + } + return null; +} diff --git a/packages/skills/__tests__/prompt-builder.test.ts b/packages/skills/__tests__/prompt-builder.test.ts index c43e88d..f244ebe 100644 --- a/packages/skills/__tests__/prompt-builder.test.ts +++ b/packages/skills/__tests__/prompt-builder.test.ts @@ -6,6 +6,7 @@ import type { L3Block } from "../../core/l3.js"; import type { L4Flow } from "../../core/l4.js"; import type { L5Blueprint } from "../../core/l5.js"; import type { SkillInput, ResolvedContext, SkillConfig } from "../../core/skill.js"; +import type { RefFile } from "../../core/store.js"; const baseRevision = { rev: 1, @@ -326,3 +327,119 @@ describe("renderPrompt", () => { } }); }); + +describe("buildPrompt — refs", () => { + const textRef: RefFile = { + name: "algorithm.md", + path: "nodes/validate-order/refs/algorithm.md", + isText: true, + content: "# Luhn Check\nUse mod 10 algorithm", + }; + + const binaryRef: RefFile = { + name: "design.png", + path: "nodes/validate-order/refs/design.png", + isText: false, + }; + + const tsRef: RefFile = { + name: "reference.ts", + path: "nodes/validate-order/refs/reference.ts", + isText: true, + content: "export function validate(n: number): boolean { return true; }", + }; + + it("compile prompt includes Reference Materials when refs present", () => { + const input = makeInput("compile", { + l5: makeL5(), + l3: makeL3(), + l4: makeL4(), + refs: [textRef, binaryRef], + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("### Reference Materials"); + expect(prompt.input).toContain("#### algorithm.md"); + expect(prompt.input).toContain("Luhn Check"); + }); + + it("compile prompt omits Reference Materials when refs is empty", () => { + const input = makeInput("compile", { + l5: makeL5(), + l3: makeL3(), + l4: makeL4(), + refs: [], + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).not.toContain("Reference Materials"); + }); + + it("compile prompt omits Reference Materials when refs is undefined", () => { + const input = makeInput("compile", { + l5: makeL5(), + l3: makeL3(), + l4: makeL4(), + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).not.toContain("Reference Materials"); + }); + + it("text refs are inlined with content", () => { + const input = makeInput("compile", { + l3: makeL3(), + refs: [tsRef], + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("#### reference.ts"); + expect(prompt.input).toContain("export function validate"); + expect(prompt.input).toContain("```ts"); + }); + + it("binary refs show path instead of content", () => { + const input = makeInput("compile", { + l3: makeL3(), + refs: [binaryRef], + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("#### design.png (binary)"); + expect(prompt.input).toContain("File path: nodes/validate-order/refs/design.png"); + }); + + it("recompile prompt includes refs", () => { + const input = makeInput("recompile", { + l3: makeL3(), + l2: makeL2(), + l4: makeL4(), + l1Files: [{ path: "src/validate-order.ts", content: "export function validate() {}" }], + refs: [textRef], + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("### Reference Materials"); + expect(prompt.input).toContain("Luhn Check"); + }); + + it("review prompt includes refs", () => { + const input = makeInput("review", { + l3: makeL3(), + l2: makeL2(), + l1Files: [{ path: "src/validate-order.ts", content: "// code" }], + refs: [textRef], + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("### Reference Materials"); + expect(prompt.input).toContain("Luhn Check"); + }); +}); diff --git a/packages/skills/__tests__/scan-prompts.test.ts b/packages/skills/__tests__/scan-prompts.test.ts new file mode 100644 index 0000000..b618b97 --- /dev/null +++ b/packages/skills/__tests__/scan-prompts.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from "vitest"; +import { buildScanL3Prompt, buildScanL4Prompt, buildScanL5Prompt } from "../prompts/scan.js"; +import type { L3Block } from "../../core/l3.js"; +import type { L4Flow } from "../../core/l4.js"; +import type { ScanContext } from "../../core/scan.js"; + +// ── Fixtures ── + +const baseRevision = { + rev: 1, + parentRev: null as number | null, + source: { type: "init" as const }, + timestamp: "2024-01-01T00:00:00Z", +}; + +const makeScanContext = ( + files: Array<{ + filePath: string; + exports?: Array<{ name: string; kind: string; signature: string }>; + }>, + truncated = false, +): ScanContext => { + const scannedFiles = files.map((f) => ({ + filePath: f.filePath, + exports: (f.exports ?? []) as ScanContext["files"][number]["exports"], + })); + return { + files: scannedFiles, + summary: { + totalFiles: scannedFiles.length, + totalExports: scannedFiles.reduce((sum, f) => sum + f.exports.length, 0), + truncated, + }, + }; +}; + +const makeL3 = (id: string): L3Block => ({ + id, + name: `Block ${id}`, + input: [{ name: "req", type: "Request" }], + output: [{ name: "res", type: "Response" }], + validate: {}, + constraints: [], + description: "test", + contentHash: "hash123", + revision: baseRevision, +}); + +const makeFlow = (id: string, blockRefs: string[]): L4Flow => ({ + kind: "flow" as const, + id, + name: `Flow ${id}`, + steps: blockRefs.map((ref, i) => ({ + id: `step-${String(i)}`, + action: "process" as const, + blockRef: ref, + next: i < blockRefs.length - 1 ? `step-${String(i + 1)}` : null, + })), + dataFlows: [], + contentHash: "hash456", + revision: baseRevision, +}); + +// ── buildScanL3Prompt (Phase 1) ── + +describe("buildScanL3Prompt", () => { + it("produces valid markdown with complexity header", () => { + const ctx = makeScanContext([ + { + filePath: "src/index.ts", + exports: [{ name: "hello", kind: "function", signature: "() => void" }], + }, + ]); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).toMatch(/^---\ncomplexity: heavy\n---/); + expect(result).toContain("# Reverse-Engineer L3 Contracts"); + }); + + it("includes scanned file tree with exports", () => { + const ctx = makeScanContext([ + { + filePath: "src/handler.ts", + exports: [ + { name: "handleRequest", kind: "function", signature: "(req: Request) => Response" }, + ], + }, + ]); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).toContain("src/handler.ts"); + expect(result).toContain("handleRequest"); + expect(result).toContain("(req: Request) => Response"); + }); + + it("includes user intent when provided", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL3Prompt({ scanContext: ctx, userIntent: "order management system" }); + + expect(result).toContain("System Intent"); + expect(result).toContain("order management system"); + }); + + it("omits user intent section when not provided", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).not.toContain("System Intent"); + }); + + it("includes language directive for non-English", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL3Prompt({ scanContext: ctx, language: "zh" }); + + expect(result).toContain("Chinese"); + expect(result).toContain("IMPORTANT"); + }); + + it("does not include language directive for English", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL3Prompt({ scanContext: ctx, language: "en" }); + + expect(result).not.toContain("IMPORTANT: All human-readable text"); + }); + + it("shows truncation notice when files are truncated", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }], true); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).toContain("truncated"); + }); + + it("includes summary line with file and export counts", () => { + const ctx = makeScanContext([ + { + filePath: "src/a.ts", + exports: [ + { name: "foo", kind: "function", signature: "() => void" }, + { name: "bar", kind: "variable", signature: "string" }, + ], + }, + { filePath: "src/b.ts" }, + ]); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).toContain("2 files"); + expect(result).toContain("2 exported symbols"); + }); + + it("includes L3 schema example", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).toContain('"id": ""'); + expect(result).toContain("validate"); + expect(result).toContain("constraints"); + }); + + it("instructs to run forge rehash after writing", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL3Prompt({ scanContext: ctx }); + + expect(result).toContain("forge rehash l3"); + expect(result).toContain("forge prompt scan"); + }); +}); + +// ── buildScanL4Prompt (Phase 2) ── + +describe("buildScanL4Prompt", () => { + it("produces valid markdown with complexity header", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + const blocks = [makeL3("validate-order")]; + + const result = buildScanL4Prompt({ scanContext: ctx, l3Blocks: blocks }); + + expect(result).toMatch(/^---\ncomplexity: standard\n---/); + expect(result).toContain("# Infer L4 Flows"); + }); + + it("includes L3 block summary with signatures", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + const blocks = [makeL3("validate-order"), makeL3("process-payment")]; + + const result = buildScanL4Prompt({ scanContext: ctx, l3Blocks: blocks }); + + expect(result).toContain("validate-order"); + expect(result).toContain("process-payment"); + expect(result).toContain("Request"); + expect(result).toContain("Response"); + }); + + it("includes code structure for import pattern analysis", () => { + const ctx = makeScanContext([ + { filePath: "src/order.ts", exports: [{ name: "Order", kind: "class", signature: "Order" }] }, + { filePath: "src/payment.ts" }, + ]); + const blocks = [makeL3("order")]; + + const result = buildScanL4Prompt({ scanContext: ctx, l3Blocks: blocks }); + + expect(result).toContain("src/order.ts"); + expect(result).toContain("src/payment.ts"); + }); + + it("includes user intent when provided", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL4Prompt({ + scanContext: ctx, + l3Blocks: [makeL3("x")], + userIntent: "e-commerce platform", + }); + + expect(result).toContain("e-commerce platform"); + }); + + it("includes L4 flow schema example", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL4Prompt({ scanContext: ctx, l3Blocks: [makeL3("x")] }); + + expect(result).toContain('"kind": "flow"'); + expect(result).toContain("dataFlows"); + expect(result).toContain("blockRef"); + }); + + it("instructs to run forge rehash after writing", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL4Prompt({ scanContext: ctx, l3Blocks: [makeL3("x")] }); + + expect(result).toContain("forge rehash l4"); + expect(result).toContain("forge prompt scan"); + }); +}); + +// ── buildScanL5Prompt (Phase 3) ── + +describe("buildScanL5Prompt", () => { + it("produces valid markdown with complexity header", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + const blocks = [makeL3("x")]; + const flows = [makeFlow("f1", ["x"])]; + + const result = buildScanL5Prompt({ scanContext: ctx, l3Blocks: blocks, l4Flows: flows }); + + expect(result).toMatch(/^---\ncomplexity: standard\n---/); + expect(result).toContain("# Synthesize L5 Blueprint"); + }); + + it("includes L4 flow summary", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + const flows = [makeFlow("order-flow", ["validate"]), makeFlow("payment-flow", ["charge"])]; + + const result = buildScanL5Prompt({ + scanContext: ctx, + l3Blocks: [makeL3("validate"), makeL3("charge")], + l4Flows: flows, + }); + + expect(result).toContain("order-flow"); + expect(result).toContain("payment-flow"); + }); + + it("includes L3 block summary", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + const blocks = [makeL3("validate-order"), makeL3("process-payment")]; + + const result = buildScanL5Prompt({ + scanContext: ctx, + l3Blocks: blocks, + l4Flows: [makeFlow("f1", ["validate-order"])], + }); + + expect(result).toContain("validate-order"); + expect(result).toContain("process-payment"); + }); + + it("includes user intent when provided", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL5Prompt({ + scanContext: ctx, + l3Blocks: [makeL3("x")], + l4Flows: [makeFlow("f1", ["x"])], + userIntent: "SaaS billing platform", + }); + + expect(result).toContain("SaaS billing platform"); + }); + + it("includes L5 schema example", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL5Prompt({ + scanContext: ctx, + l3Blocks: [makeL3("x")], + l4Flows: [makeFlow("f1", ["x"])], + }); + + expect(result).toContain('"intent"'); + expect(result).toContain('"domains"'); + expect(result).toContain('"integrations"'); + }); + + it("instructs to run forge rehash and forge check after writing", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL5Prompt({ + scanContext: ctx, + l3Blocks: [makeL3("x")], + l4Flows: [makeFlow("f1", ["x"])], + }); + + expect(result).toContain("forge rehash l5"); + expect(result).toContain("forge check"); + }); + + it("includes language directive for non-English", () => { + const ctx = makeScanContext([{ filePath: "src/index.ts" }]); + + const result = buildScanL5Prompt({ + scanContext: ctx, + l3Blocks: [makeL3("x")], + l4Flows: [makeFlow("f1", ["x"])], + language: "zh", + }); + + expect(result).toContain("Chinese"); + }); +}); diff --git a/packages/skills/adapters/shared.ts b/packages/skills/adapters/shared.ts index 3201736..9c2874c 100644 --- a/packages/skills/adapters/shared.ts +++ b/packages/skills/adapters/shared.ts @@ -40,7 +40,8 @@ export function getProtocolSection(language: string, modelTierLine: string): str - JSON 中 contentHash 和 revision 写占位值,\`forge rehash\` 会修正 - 尽量并行派发无依赖的 subagent - 做不到就报错,说清哪层什么问题——用户是反向反馈回路 -- 如果 nodes//docs.md 存在,compile/recompile prompt 会自动包含文档内容`; +- 如果 nodes//docs.md 存在,compile/recompile prompt 会自动包含文档内容 +- 如果 nodes//refs/ 存在,compile/recompile/review prompt 会自动包含参考材料`; } return `## Protocol (one-time declaration) @@ -55,7 +56,8 @@ export function getProtocolSection(language: string, modelTierLine: string): str - Write placeholder values for contentHash and revision in JSON; \`forge rehash\` will fix them - Dispatch independent subagents in parallel when possible - Report errors when unable to proceed, clearly stating which layer and what the issue is — the user is the reverse feedback loop -- If nodes//docs.md exists, compile/recompile prompts will automatically include its content`; +- If nodes//docs.md exists, compile/recompile prompts will automatically include its content +- If nodes//refs/ exists, compile/recompile/review prompts will automatically include reference materials`; } // ── Skill file: Workflow content (Step 0 through View) ── @@ -169,13 +171,16 @@ const workflowZh = `## Step 0: 诊断路由 - 运行 \`forge check --json\`(忽略错误)+ \`forge view l5\` + 检查 .svp/ 是否存在 - 根据结果判断: - **无 .svp/**:告知用户先运行 \`forge init\`,停止 - - **空项目**(无 L4/L3)→ 进入 **Build** + - **空项目**(无 L4/L3)→ 问用户选择模式: + (a) Build — 从零自上而下构建 + (b) Scan — 从已有代码自下而上逆向生成 - **有数据** → 问用户选择模式: (a) Build — 从零构建 (b) Add — 添加新功能 (c) Change — 修改已有功能 (d) Fix — 修复 check 问题 (e) View — 查看当前结构 + (f) Scan — 从已有代码逆向生成 --- @@ -186,7 +191,15 @@ const workflowZh = `## Step 0: 诊断路由 - 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) - Subagent 输出 L5 JSON → 写入 .svp/l5.json - [Toolchain] 运行 \`forge rehash l5\` -- 展示 \`forge view l5\` 给用户确认 + +**[对齐] L5 Overview — 必须等待用户确认后才能继续:** +- [AI] 生成一份用户能看懂的 L5 Overview,用自然语言解释: + - 系统解决什么问题、成功标准是什么 + - 有哪些领域(domains),它们之间的依赖关系 + - 有哪些外部集成,各自的职责 + - 有哪些约束条件 +- 用户可能会要求调整意图、增减领域、修改约束 → 迭代修改 L5 直到用户满意 +- **用户确认后才进入 Step 2** ### Step 2: [AI] 设计 L4 Artifacts 根据系统类型选择 L4 变体: @@ -197,7 +210,15 @@ const workflowZh = `## Step 0: 诊断路由 - 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) - Subagent 输出 L4 JSON → 写入 .svp/l4/.json - [Toolchain] 运行 \`forge rehash l4\` -- 展示 \`forge view l4\` 给用户确认 + +**[对齐] L4 Overview — 必须等待用户确认后才能继续:** +- [AI] 生成一份用户能看懂的 L4 Overview,用自然语言解释: + - 系统有哪些流程/事件图/状态机 + - 每个流程的触发方式、步骤链、数据流方向 + - 各步骤引用的 blockRef 是什么(即将成为 L3 的契约) + - 流程之间是否有依赖或共享数据 +- 用户可能会要求调整流程编排、增删步骤、修改数据流 → 迭代修改 L4 直到用户满意 +- **用户确认后才进入 Step 3** ### Step 3: [AI] 设计 L3 Contracts(并行派发) 对每个 L4 step 的 blockRef: @@ -207,6 +228,15 @@ const workflowZh = `## Step 0: 诊断路由 - [Toolchain] 运行 \`forge rehash l3/\` - **无依赖的 block 并行派发** +**[对齐] L3 Overview — 必须等待用户确认后才能继续:** +- [AI] 生成一份用户能看懂的 L3 Overview,用自然语言解释: + - 所有 L3 block 的一览表:名称、职责(一句话)、输入输出 + - 标记复杂度信号:哪些 block 的 pin 数量多、依赖多 + - 各 block 和 L4 step 的映射关系 + - 是否有 block 职责过宽(如一个 block 处理所有路由),建议拆分 +- 用户可能会要求调整 block 粒度、合并或拆分 block、修改 pin 定义 → 迭代修改 L3 直到用户满意 +- **用户确认后才进入 Step 4** + ### Step 4: [Toolchain] 获取编译任务 - 运行 \`forge compile-plan\` 获取编译任务列表 @@ -229,9 +259,13 @@ const workflowZh = `## Step 0: 诊断路由 ## Add(向已有系统添加功能) +### Step 0: [Toolchain] 创建变更集 +- 运行 \`forge changeset start --reason "<变更原因>"\` 记录基线快照 + ### Step 1: [Toolchain] 了解当前结构 - 运行 \`forge view l5\` 和 \`forge view l4/\` 了解现有架构 - 确定新功能属于哪个 L4 flow(或需要新 flow) +- 如有设计稿或参考实现,放入 \`nodes//refs/\` 文件夹 ### Step 2: [AI] 修改 L4 Flow - 编辑对应的 .svp/l4/.json,添加新 step + blockRef @@ -255,10 +289,16 @@ const workflowZh = `## Step 0: 诊断路由 - \`forge link --files \` - \`forge check\` 确认全绿 +### Step 6: [Toolchain] 完成变更集 +- 运行 \`forge changeset complete\` 记录本次变更涉及的所有 artifact 变动 + --- ## Change(修改已有需求) +### Step 0: [Toolchain] 创建变更集 +- 运行 \`forge changeset start --reason "<变更原因>"\` 记录基线快照 + ### Step 1: [Toolchain] 诊断当前状态 - 运行 \`forge check\` 确认当前一致性状态 - 运行 \`forge view l5\` + \`forge view l4\` + \`forge view l3\` 了解结构 @@ -289,6 +329,9 @@ const workflowZh = `## Step 0: 诊断路由 - \`forge link --files \` - \`forge check\` 确认全绿 +### Step 7: [Toolchain] 完成变更集 +- 运行 \`forge changeset complete\` 记录本次变更涉及的所有 artifact 变动 + --- ## Fix(修复 check 发现的问题) @@ -332,6 +375,35 @@ const workflowZh = `## Step 0: 诊断路由 - 运行 \`forge view l5\` + \`forge view l4\` + \`forge view l3\` 展示完整系统结构 - 如有 L2 映射,也展示 \`forge view l2\` +--- + +## Scan(从已有代码逆向生成 SVP) + +### Phase 1: [AI] 提取 L3 Contracts +- 运行 \`forge prompt scan [--dir ] [--intent "<描述>"]\`(自动检测 Phase 1) +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 分析代码,生成 L3 block → 写入 .svp/l3/ +- [Toolchain] 运行 \`forge rehash l3\` +- 展示 \`forge view l3\` 给用户确认 + +### Phase 2: [AI] 推断 L4 Flows +- 运行 \`forge prompt scan\`(自动检测 Phase 2) +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 分析 L3 block 关系,生成 L4 flow → 写入 .svp/l4/ +- [Toolchain] 运行 \`forge rehash l4\` +- 展示 \`forge view l4\` 给用户确认 + +### Phase 3: [AI] 综合 L5 Blueprint +- 运行 \`forge prompt scan\`(自动检测 Phase 3) +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 从 L3+L4 综合 L5 → 写入 .svp/l5.json +- [Toolchain] 运行 \`forge rehash l5\` +- 展示 \`forge view l5\` 给用户确认 + +### Phase 4: [Toolchain] 创建 L2 映射 +- 对每个 L3 block,运行 \`forge link --files \` +- 运行 \`forge check\` 验证一致性 + $ARGUMENTS`; const workflowEn = `## Step 0: Diagnostic Router @@ -339,13 +411,16 @@ const workflowEn = `## Step 0: Diagnostic Router - Run \`forge check --json\` (ignore errors) + \`forge view l5\` + check whether .svp/ exists - Based on the result, determine: - **No .svp/**: Tell user to run \`forge init\` first, then stop - - **Empty project** (no L4/L3) → Enter **Build** + - **Empty project** (no L4/L3) → Ask user to choose a mode: + (a) Build — build from scratch (top-down) + (b) Scan — reverse-engineer from existing code (bottom-up) - **Has data** → Ask user to choose a mode: (a) Build — build from scratch (b) Add — add new feature (c) Change — modify existing feature (d) Fix — fix check issues (e) View — view current structure + (f) Scan — reverse-engineer from existing code --- @@ -356,7 +431,15 @@ const workflowEn = `## Step 0: Diagnostic Router - Dispatch stdout output to subagent (read complexity to select model tier) - Subagent outputs L5 JSON → write to .svp/l5.json - [Toolchain] Run \`forge rehash l5\` -- Show \`forge view l5\` to user for confirmation + +**[Alignment] L5 Overview — MUST wait for user confirmation before proceeding:** +- [AI] Generate a human-readable L5 Overview in natural language: + - What problem the system solves, what success looks like + - What domains exist and how they depend on each other + - What external integrations exist and their roles + - What constraints apply +- User may request changes to intent, domains, constraints → iterate on L5 until user is satisfied +- **Proceed to Step 2 only after user confirms** ### Step 2: [AI] Design L4 Artifacts Choose L4 variant based on system type: @@ -367,7 +450,15 @@ Choose L4 variant based on system type: - Dispatch stdout output to subagent (read complexity to select model tier) - Subagent outputs L4 JSON → write to .svp/l4/.json - [Toolchain] Run \`forge rehash l4\` -- Show \`forge view l4\` to user for confirmation + +**[Alignment] L4 Overview — MUST wait for user confirmation before proceeding:** +- [AI] Generate a human-readable L4 Overview in natural language: + - What flows/event graphs/state machines the system has + - How each flow is triggered, its step chain, and data flow direction + - What blockRefs each step points to (these become L3 contracts) + - Whether flows share data or have dependencies between them +- User may request changes to flow orchestration, add/remove steps, modify data flows → iterate on L4 until user is satisfied +- **Proceed to Step 3 only after user confirms** ### Step 3: [AI] Design L3 Contracts (dispatch in parallel) For each blockRef in L4 steps: @@ -377,6 +468,15 @@ For each blockRef in L4 steps: - [Toolchain] Run \`forge rehash l3/\` - **Dispatch independent blocks in parallel** +**[Alignment] L3 Overview — MUST wait for user confirmation before proceeding:** +- [AI] Generate a human-readable L3 Overview in natural language: + - Summary table of all L3 blocks: name, responsibility (one sentence), inputs/outputs + - Flag complexity signals: blocks with many pins or high dependency count + - Mapping between blocks and L4 steps + - Whether any block has overly broad responsibility (e.g., one block handling all routes) — suggest splitting +- User may request changes to block granularity, merge or split blocks, modify pin definitions → iterate on L3 until user is satisfied +- **Proceed to Step 4 only after user confirms** + ### Step 4: [Toolchain] Get Compile Tasks - Run \`forge compile-plan\` to get the compile task list @@ -399,9 +499,13 @@ For each compile task: ## Add (add feature to existing system) +### Step 0: [Toolchain] Create Changeset +- Run \`forge changeset start --reason ""\` to snapshot baseline + ### Step 1: [Toolchain] Understand Current Structure - Run \`forge view l5\` and \`forge view l4/\` to understand the existing architecture - Determine which L4 flow the new feature belongs to (or whether a new flow is needed) +- If you have design mockups or reference implementations, place them in \`nodes//refs/\` ### Step 2: [AI] Modify L4 Flow - Edit the corresponding .svp/l4/.json, add a new step + blockRef @@ -425,10 +529,16 @@ For each compile task: - \`forge link --files \` - \`forge check\` to confirm all green +### Step 6: [Toolchain] Complete Changeset +- Run \`forge changeset complete\` to record all artifact changes in this changeset + --- ## Change (modify existing requirement) +### Step 0: [Toolchain] Create Changeset +- Run \`forge changeset start --reason ""\` to snapshot baseline + ### Step 1: [Toolchain] Diagnose Current State - Run \`forge check\` to confirm current consistency state - Run \`forge view l5\` + \`forge view l4\` + \`forge view l3\` to understand the structure @@ -459,6 +569,9 @@ For each recompile task: - \`forge link --files \` - \`forge check\` to confirm all green +### Step 7: [Toolchain] Complete Changeset +- Run \`forge changeset complete\` to record all artifact changes in this changeset + --- ## Fix (fix issues found by check) @@ -502,6 +615,35 @@ For each recompile task: - Run \`forge view l5\` + \`forge view l4\` + \`forge view l3\` to show full system structure - If L2 mappings exist, also show \`forge view l2\` +--- + +## Scan (reverse-engineer SVP from existing code) + +### Phase 1: [AI] Extract L3 Contracts +- Run \`forge prompt scan [--dir ] [--intent ""]\` (auto-detects Phase 1) +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent analyzes code, generates L3 blocks → writes to .svp/l3/ +- [Toolchain] Run \`forge rehash l3\` +- Show \`forge view l3\` to user for confirmation + +### Phase 2: [AI] Infer L4 Flows +- Run \`forge prompt scan\` (auto-detects Phase 2) +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent analyzes L3 block relationships, generates L4 flows → writes to .svp/l4/ +- [Toolchain] Run \`forge rehash l4\` +- Show \`forge view l4\` to user for confirmation + +### Phase 3: [AI] Synthesize L5 Blueprint +- Run \`forge prompt scan\` (auto-detects Phase 3) +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent synthesizes L5 from L3+L4 → writes to .svp/l5.json +- [Toolchain] Run \`forge rehash l5\` +- Show \`forge view l5\` to user for confirmation + +### Phase 4: [Toolchain] Create L2 Mappings +- For each L3 block, run \`forge link --files \` +- Run \`forge check\` to verify consistency + $ARGUMENTS`; // ── Private: Context body templates ── @@ -553,6 +695,24 @@ graphs/ - 不影响 contentHash——是补充信息,不是契约 - 用途:设计意图、边界情况、错误策略、集成约定、示例 +### 参考材料 (refs/) + +每个节点/图可有可选的 \`refs/\` 文件夹,附加任意参考文件(设计稿、算法规格、参考实现等): + +\`\`\` +nodes// +├── docs.md # 可选:补充文档 +└── refs/ # 可选:参考材料文件夹 + ├── design.png # UI 设计稿 + ├── algorithm.md # 算法规格 + └── reference.ts # 参考实现 +\`\`\` + +- 文本文件(.md/.ts/.py 等)内容直接内联到 prompt 中 +- 二进制文件(.png/.pdf 等)以路径形式列出 +- 不影响 contentHash,也不纳入 \`forge docs check\` 覆盖检查 +- 自动加载到 compile/recompile/review prompt 中 + ### AI vs Toolchain 作用域 | 作用域 | 操作 | 方式 | @@ -561,6 +721,7 @@ graphs/ | **AI** | 编译 L3→L1 代码 | \`forge prompt compile/recompile\` → subagent | | **AI** | 审查漂移 | \`forge prompt review\` → subagent | | **AI** | 修复断裂引用 | \`forge prompt update-ref\` → subagent | +| **AI** | 从已有代码逆向生成 | \`forge prompt scan\` → subagent | | **Toolchain** | 校验一致性 | \`forge check\` | | **Toolchain** | 渲染层视图 | \`forge view\` | | **Toolchain** | 生成编译任务列表 | \`forge compile-plan\` | @@ -600,6 +761,11 @@ SVP prompt 包含 \`complexity\` front-matter 字段,指示任务难度: | \`forge rehash [target]\` | 重算 contentHash + 递增 revision | | \`forge link --files \` | 创建/更新 L2 code block 映射 | | \`forge prompt \` | 生成上下文感知的 AI 提示词到 stdout | +| \`forge changeset start --reason "..."\` | 创建变更集,快照基线 | +| \`forge changeset complete\` | 完成活跃变更集,计算差异 | +| \`forge changeset list\` | 列出所有变更集 | +| \`forge changeset view [id]\` | 查看变更集差异(默认活跃) | +| \`forge changeset abandon [id]\` | 放弃活跃变更集 | ### Prompt 命令 @@ -612,6 +778,7 @@ SVP prompt 包含 \`complexity\` front-matter 字段,指示任务难度: | \`forge prompt design-l5 --intent "..."\` | 生成 L5 设计提示词 | | \`forge prompt design-l4 --intent "..." [--kind flow|event-graph|state-machine]\` | 生成 L4 设计提示词 | | \`forge prompt design-l3 --flow --step --intent "..."\` | 生成 L3 设计提示词 | +| \`forge prompt scan [--dir ] [--intent "..."]\` | 从已有代码逆向生成 SVP(自动检测阶段) | ### Slash 命令 @@ -702,6 +869,24 @@ graphs/ - Does NOT affect contentHash — it's supplementary, not contractual - Use it for: design intent, edge cases, error strategy, integration notes, examples +### Reference Materials (refs/) + +Each node/graph can have an optional \`refs/\` folder for arbitrary reference files (mockups, algorithm specs, reference implementations, etc.): + +\`\`\` +nodes// +├── docs.md # Optional: supplementary documentation +└── refs/ # Optional: reference materials folder + ├── design.png # UI mockup + ├── algorithm.md # Algorithm spec + └── reference.ts # Reference implementation +\`\`\` + +- Text files (.md/.ts/.py etc.) are inlined into the prompt +- Binary files (.png/.pdf etc.) are listed by path +- Does NOT affect contentHash, and is NOT checked by \`forge docs check\` +- Auto-loaded into compile/recompile/review prompts + ### AI vs Toolchain Scope | Scope | Operation | Method | @@ -710,6 +895,7 @@ graphs/ | **AI** | Compile L3→L1 code | \`forge prompt compile/recompile\` → subagent | | **AI** | Review drift | \`forge prompt review\` → subagent | | **AI** | Fix broken references | \`forge prompt update-ref\` → subagent | +| **AI** | Reverse-engineer from code | \`forge prompt scan\` → subagent | | **Toolchain** | Validate consistency | \`forge check\` | | **Toolchain** | Render layer views | \`forge view\` | | **Toolchain** | Generate compile task list | \`forge compile-plan\` | @@ -750,6 +936,11 @@ and pass the corresponding model parameter. | \`forge rehash [target]\` | Recompute contentHash + bump revision | | \`forge link --files \` | Create/update L2 code block mapping | | \`forge prompt \` | Generate context-aware AI prompt to stdout | +| \`forge changeset start --reason "..."\` | Start changeset, snapshot baseline | +| \`forge changeset complete\` | Complete active changeset, compute diff | +| \`forge changeset list\` | List all changesets | +| \`forge changeset view [id]\` | View changeset diff (defaults to active) | +| \`forge changeset abandon [id]\` | Abandon active changeset | ### Prompt Commands @@ -762,6 +953,7 @@ and pass the corresponding model parameter. | \`forge prompt design-l5 --intent "..."\` | Generate L5 design prompt | | \`forge prompt design-l4 --intent "..." [--kind flow|event-graph|state-machine]\` | Generate L4 design prompt | | \`forge prompt design-l3 --flow --step --intent "..."\` | Generate L3 design prompt | +| \`forge prompt scan [--dir ] [--intent "..."]\` | Reverse-engineer SVP from existing code (auto-detects phase) | ### Slash Commands diff --git a/packages/skills/index.ts b/packages/skills/index.ts index 0e3bdf3..f5e1b8c 100644 --- a/packages/skills/index.ts +++ b/packages/skills/index.ts @@ -15,6 +15,10 @@ export type { SlashCommandTemplate } from "./templates/slash-commands.js"; export { generateClaudeMdSection } from "./templates/claude-md.js"; +// Scan prompt builders (brownfield reverse generation) +export { buildScanL3Prompt, buildScanL4Prompt, buildScanL5Prompt } from "./prompts/scan.js"; +export type { ScanL3Input, ScanL4Input, ScanL5Input } from "./prompts/scan.js"; + // Host adapter system export { getAdapter, getAllAdapterIds } from "./adapters/index.js"; export type { HostId, HostAdapter, SkillFile } from "./adapters/index.js"; diff --git a/packages/skills/prompt-builder.ts b/packages/skills/prompt-builder.ts index e7a6189..8272b01 100644 --- a/packages/skills/prompt-builder.ts +++ b/packages/skills/prompt-builder.ts @@ -6,6 +6,7 @@ import { getLanguage, languageDirective } from "../core/i18n.js"; import { viewL2Detail, viewL3Detail, viewL4Detail, viewL5Overview } from "../core/view.js"; import type { Complexity, TaskAction } from "../core/compile-plan.js"; import type { SkillInput } from "../core/skill.js"; +import type { RefFile } from "../core/store.js"; export interface StructuredPrompt { readonly role: string; @@ -74,6 +75,12 @@ const COMMON_RULES = [ "- Use forge link to create/update L2 after generating L1 code", "- Write placeholder for contentHash in JSON — rehash will fix it", "- Keep implementation minimal — satisfy the contract, nothing more", + "", + "**Implementation quality guidelines:**", + "- One L3 block CAN produce multiple L1 files — split when a single file would exceed ~200 lines or mix unrelated concerns", + "- If the L3 contract covers multiple domains (e.g., routes for all entities), split into one file per domain and list all in forge link --files", + "- Each file should be independently understandable — avoid files that only make sense when read alongside another", + "- Prefer explicit over clever — straightforward code is easier to verify against the L3 contract", ].join("\n"); // ── 主入口 ── @@ -180,6 +187,11 @@ function buildCompileInput(input: SkillInput): string[] { parts.push("", "### Documentation", "", resolved.docs); } + // 参考材料 + if (resolved.refs !== undefined && resolved.refs.length > 0) { + parts.push("", "### Reference Materials", "", formatRefs(resolved.refs)); + } + return parts; } @@ -213,6 +225,11 @@ function buildRecompileInput(input: SkillInput): string[] { parts.push("", "### Documentation", "", resolved.docs); } + // 参考材料 + if (resolved.refs !== undefined && resolved.refs.length > 0) { + parts.push("", "### Reference Materials", "", formatRefs(resolved.refs)); + } + return parts; } @@ -246,6 +263,11 @@ function buildReviewInput(input: SkillInput): string[] { parts.push("", "### Documentation", "", resolved.docs); } + // 参考材料 + if (resolved.refs !== undefined && resolved.refs.length > 0) { + parts.push("", "### Reference Materials", "", formatRefs(resolved.refs)); + } + return parts; } @@ -265,3 +287,28 @@ function buildUpdateRefInput(input: SkillInput): string[] { return parts; } + +/** Format reference files for prompt injection */ +function formatRefs(refs: readonly RefFile[]): string { + const parts: string[] = [ + "The following reference files are available for this block.", + "Use them to guide your implementation.", + ]; + + for (const ref of refs) { + if (ref.isText && ref.content !== undefined) { + const ext = ref.name.split(".").pop() ?? ""; + parts.push("", `#### ${ref.name}`, "", "```" + ext, ref.content, "```"); + } else { + parts.push( + "", + `#### ${ref.name} (binary)`, + "", + `File path: ${ref.path}`, + "(Read this file for the visual/binary reference)", + ); + } + } + + return parts.join("\n"); +} diff --git a/packages/skills/prompts/design-l3.ts b/packages/skills/prompts/design-l3.ts index 73ea2b9..b7913ad 100644 --- a/packages/skills/prompts/design-l3.ts +++ b/packages/skills/prompts/design-l3.ts @@ -26,6 +26,7 @@ export interface DesignL3Input { readonly existingBlock?: L3Block; readonly userIntent: string; readonly language?: string; + readonly docs?: string; } const L3_SCHEMA_EXAMPLE = `{ @@ -128,6 +129,7 @@ export function buildDesignL3Prompt(input: DesignL3Input): string { "", input.userIntent, "", + ...(input.docs === undefined ? [] : ["## Module Documentation", "", input.docs, ""]), "## Current State", "", currentSection, @@ -166,6 +168,13 @@ export function buildDesignL3Prompt(input: DesignL3Input): string { "- description explains HOW to transform input to output", "- Ensure pin types are compatible with upstream/downstream blocks", "- Write 'placeholder' for contentHash — rehash will fix it", + "", + "**Design quality guidelines:**", + "- A block should have ONE clear responsibility — if you need 'and' to describe it, consider splitting", + "- Input pins > 5 is a complexity signal — the block may be doing too much", + "- Output pins > 3 is a complexity signal — consider whether some outputs belong to a separate block", + "- A block that handles multiple domains (e.g., 'all routes', 'all validations') should almost always be split per domain", + "- One L3 block can map to multiple L1 files via L2 — do NOT compress a multi-file responsibility into one block just because of 1:1 naming", ].join("\n") + languageDirective(input.language ?? "en") ); diff --git a/packages/skills/prompts/design-l4-event-graph.ts b/packages/skills/prompts/design-l4-event-graph.ts index 00b2d37..c2587a8 100644 --- a/packages/skills/prompts/design-l4-event-graph.ts +++ b/packages/skills/prompts/design-l4-event-graph.ts @@ -15,6 +15,7 @@ export interface DesignL4EventGraphInput { readonly userIntent: string; readonly targetId?: string; readonly language?: string; + readonly docs?: string; } const EVENT_GRAPH_SCHEMA_EXAMPLE = `{ @@ -213,6 +214,7 @@ export function buildDesignL4EventGraphPrompt(input: DesignL4EventGraphInput): s "", input.userIntent, "", + ...(input.docs === undefined ? [] : ["## Graph Documentation", "", input.docs, ""]), "## Existing L4 Artifacts", "", existingSection, diff --git a/packages/skills/prompts/design-l4-state-machine.ts b/packages/skills/prompts/design-l4-state-machine.ts index 4b89f06..cef1ce0 100644 --- a/packages/skills/prompts/design-l4-state-machine.ts +++ b/packages/skills/prompts/design-l4-state-machine.ts @@ -15,6 +15,7 @@ export interface DesignL4StateMachineInput { readonly userIntent: string; readonly targetId?: string; readonly language?: string; + readonly docs?: string; } const STATE_MACHINE_SCHEMA_EXAMPLE = `{ @@ -199,6 +200,7 @@ export function buildDesignL4StateMachinePrompt(input: DesignL4StateMachineInput "", input.userIntent, "", + ...(input.docs === undefined ? [] : ["## Graph Documentation", "", input.docs, ""]), "## Existing L4 Artifacts", "", existingSection, diff --git a/packages/skills/prompts/design-l4.ts b/packages/skills/prompts/design-l4.ts index 00a314f..3205cc0 100644 --- a/packages/skills/prompts/design-l4.ts +++ b/packages/skills/prompts/design-l4.ts @@ -16,6 +16,7 @@ export interface DesignL4Input { readonly userIntent: string; readonly targetFlowId?: string; readonly language?: string; + readonly docs?: string; } const L4_SCHEMA_EXAMPLE = `{ @@ -76,6 +77,7 @@ export function buildDesignL4Prompt(input: DesignL4Input): string { "", input.userIntent, "", + ...(input.docs === undefined ? [] : ["## Graph Documentation", "", input.docs, ""]), "## Existing Flows", "", existingFlowsSection, @@ -112,6 +114,12 @@ export function buildDesignL4Prompt(input: DesignL4Input): string { "- Fan-out uses `parallel` action, join uses `wait` action", "- Write 'placeholder' for contentHash — rehash will fix it", "- Do NOT create L3 blocks here — only reference them by id", + "", + "**Design quality guidelines:**", + "- Each blockRef should map to a single-responsibility unit — if a step name contains 'all' or 'everything', it likely needs splitting", + "- Avoid 'god blocks' that orchestrate across all domains — prefer one flow per domain or use case", + "- Cross-cutting concerns (auth, routing, logging) should be separate blocks, not bundled into a domain block", + "- If a flow has more than 8 steps, consider whether it should be split into sub-flows", ].join("\n") + languageDirective(input.language ?? "en") ); diff --git a/packages/skills/prompts/design-l5.ts b/packages/skills/prompts/design-l5.ts index 7d415de..2edcc63 100644 --- a/packages/skills/prompts/design-l5.ts +++ b/packages/skills/prompts/design-l5.ts @@ -9,6 +9,7 @@ export interface DesignL5Input { readonly currentL5?: L5Blueprint; readonly userIntent: string; readonly language?: string; + readonly docs?: string; } const L5_SCHEMA_EXAMPLE = `{ @@ -49,6 +50,7 @@ export function buildDesignL5Prompt(input: DesignL5Input): string { "", input.userIntent, "", + ...(input.docs === undefined ? [] : ["## Project Documentation", "", input.docs, ""]), "## Current State", "", currentSection, @@ -77,6 +79,12 @@ export function buildDesignL5Prompt(input: DesignL5Input): string { "- Constraints are strings, not objects", "- Domain dependencies reference other domain names", "- Write 'placeholder' for contentHash — rehash will fix it", + "", + "**Design quality guidelines:**", + "- Each domain should have a clear, non-overlapping responsibility boundary", + "- If a domain description contains 'and' connecting unrelated concepts, consider splitting it", + "- Prefer more fine-grained domains over fewer coarse-grained ones — downstream L4/L3 design benefits from clear boundaries", + "- Integrations should be specific — 'database' is too vague, 'PostgreSQL for order data' is better", ].join("\n") + languageDirective(input.language ?? "en") ); diff --git a/packages/skills/prompts/review.ts b/packages/skills/prompts/review.ts index fe15e83..98f89a0 100644 --- a/packages/skills/prompts/review.ts +++ b/packages/skills/prompts/review.ts @@ -17,12 +17,14 @@ export function reviewInstructions(language = "en"): string { " - **L1 is wrong**: The code change was a mistake, L1 should be reverted", " - **Cosmetic only**: Formatting/naming difference that doesn't affect interface", "3. Report findings — do NOT make any changes", + "4. If Documentation is available, compare docs description with actual implementation:", + " - **Docs outdated**: Documentation describes behavior not present in the implementation", "", "## Report Format", "", "For each difference found:", "```", - "- [L3/L1/cosmetic] ", + "- [L3/L1/cosmetic/docs] ", " L3 says: ", " L1 does: ", " Recommendation: ", diff --git a/packages/skills/prompts/scan.ts b/packages/skills/prompts/scan.ts new file mode 100644 index 0000000..5d2ee43 --- /dev/null +++ b/packages/skills/prompts/scan.ts @@ -0,0 +1,309 @@ +// scan — Brownfield reverse generation prompt templates +// Three phases: L1→L3, L3→L4, L3+L4→L5 +// Each outputs a prompt instructing AI to write SVP artifacts from existing code + +import { languageDirective } from "../../core/i18n.js"; +import { complexityHeader } from "./complexity-header.js"; +import type { L3Block } from "../../core/l3.js"; +import type { L4Artifact } from "../../core/l4.js"; +import type { ScanContext } from "../../core/scan.js"; + +// ── Shared helpers ── + +function formatFileTree(ctx: ScanContext): string { + const lines: string[] = []; + for (const f of ctx.files) { + if (f.exports.length === 0) { + lines.push(`- ${f.filePath}`); + } else { + lines.push(`- ${f.filePath}`); + for (const exp of f.exports) { + lines.push(` ${exp.kind} ${exp.name}: ${exp.signature}`); + } + } + } + if (ctx.summary.truncated) { + lines.push(` ... (truncated to ${String(ctx.summary.totalFiles)} files)`); + } + return lines.join("\n"); +} + +function summaryLine(ctx: ScanContext): string { + return `${String(ctx.summary.totalFiles)} files, ${String(ctx.summary.totalExports)} exported symbols${ctx.summary.truncated ? " (truncated)" : ""}`; +} + +// ── Phase 1: L1 → L3 ── + +export interface ScanL3Input { + readonly scanContext: ScanContext; + readonly userIntent?: string; + readonly language?: string; +} + +const L3_SCHEMA_EXAMPLE = `{ + "id": "", + "name": "", + "input": [ + { "name": "request", "type": "OrderRequest" } + ], + "output": [ + { "name": "result", "type": "ValidationResult" } + ], + "validate": { + "request": "required", + "request.items": "array, min 1" + }, + "constraints": [ + "output.result.valid iff no errors" + ], + "description": "What this block does internally", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "..." } +}`; + +export function buildScanL3Prompt(input: ScanL3Input): string { + const { scanContext } = input; + + return ( + complexityHeader("heavy") + + [ + "# Reverse-Engineer L3 Contracts from Existing Code", + "", + "You are analyzing an existing codebase to extract L3 contract blocks for SVP.", + "L3 blocks are the interface specifications — each groups related exports into a logical unit.", + "", + ...(input.userIntent === undefined ? [] : ["## System Intent", "", input.userIntent, ""]), + "## Scanned Codebase", + "", + `Summary: ${summaryLine(scanContext)}`, + "", + "```", + formatFileTree(scanContext), + "```", + "", + "## Instructions", + "", + "Analyze the scanned code and group related exports into logical L3 blocks.", + "Each block represents one cohesive responsibility unit.", + "", + "For each block:", + "1. **Identify cohesion**: Group exports that work together (same domain, shared types)", + "2. **Infer input pins**: From function parameters and imported types", + "3. **Infer output pins**: From return types", + "4. **Infer validate rules**: From parameter constraints visible in signatures", + "5. **Write constraints**: Output assertions based on the function contracts", + "6. **Write description**: What the block does internally", + "", + "Write each block to `.svp/l3/.json` using this schema:", + "", + "```json", + L3_SCHEMA_EXAMPLE, + "```", + "", + "## Grouping Guidelines", + "", + "- One file with multiple related exports → usually one L3 block", + "- Multiple files sharing a domain concept → consider one L3 block", + "- A single large class → may split into multiple L3 blocks by responsibility", + "- Utility/helper files → may group into a shared utility block or skip", + "- Use kebab-case for block IDs (e.g., `validate-order`, `process-payment`)", + "", + "## After Writing", + "", + "Run `forge rehash l3` to fix all contentHash values.", + "Then run `forge prompt scan` to proceed to Phase 2 (L4 flow design).", + "", + "## Rules", + "", + "- Pin types reference TypeScript interface names from the project", + '- validate uses natural language: `"array, min 1"` not code', + "- constraints use natural language assertions about output", + "- description explains WHAT, not HOW (implementation is already in code)", + '- Write "placeholder" for contentHash — rehash will fix it', + "- Do NOT create blocks for test files, configs, or build artifacts", + ].join("\n") + + languageDirective(input.language ?? "en") + ); +} + +// ── Phase 2: L3 → L4 ── + +export interface ScanL4Input { + readonly scanContext: ScanContext; + readonly l3Blocks: readonly L3Block[]; + readonly userIntent?: string; + readonly language?: string; +} + +const L4_FLOW_SCHEMA_EXAMPLE = `{ + "kind": "flow", + "id": "", + "name": "", + "trigger": { "type": "http", "config": { "method": "POST", "path": "/api/..." } }, + "steps": [ + { "id": "s1", "action": "process", "blockRef": "", "next": "s2" }, + { "id": "s2", "action": "process", "blockRef": "", "next": null } + ], + "dataFlows": [ + { "from": "s1.result", "to": "s2.input" } + ], + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "..." } +}`; + +export function buildScanL4Prompt(input: ScanL4Input): string { + const { scanContext, l3Blocks } = input; + + const l3Summary = l3Blocks + .map((b) => { + const ins = b.input.map((p) => `${p.name}: ${p.type}`).join(", "); + const outs = b.output.map((p) => `${p.name}: ${p.type}`).join(", "); + return `- **${b.id}** (${b.name}): (${ins}) → (${outs})`; + }) + .join("\n"); + + return ( + complexityHeader("standard") + + [ + "# Infer L4 Flows from L3 Contracts and Code Structure", + "", + "You are analyzing L3 contracts and code patterns to infer L4 flow artifacts for SVP.", + "L4 describes how L3 blocks connect — the orchestration layer.", + "", + ...(input.userIntent === undefined ? [] : ["## System Intent", "", input.userIntent, ""]), + "## Existing L3 Contracts", + "", + l3Summary, + "", + "## Code Structure (for import/call pattern hints)", + "", + "```", + formatFileTree(scanContext), + "```", + "", + "## Instructions", + "", + "Analyze how L3 blocks relate to each other by examining:", + "1. **Import patterns**: Which files import from which → data flow direction", + "2. **Type dependencies**: Shared types between blocks → they likely connect in a flow", + "3. **Call patterns**: Function A calls function B → A's step comes before B's step", + "", + "Identify flows and write L4 artifacts:", + '- **Request-response pipelines**: trigger → step chain → result → use `kind: "flow"`', + '- **Event-driven patterns**: shared state + handlers → use `kind: "event-graph"`', + '- **State machines**: entity lifecycle → use `kind: "state-machine"`', + "", + "Write each artifact to `.svp/l4/.json` using this schema (flow example):", + "", + "```json", + L4_FLOW_SCHEMA_EXAMPLE, + "```", + "", + "## Wiring Guidelines", + "", + "- Each step references an L3 block via `blockRef`", + "- `dataFlows` connect output pins of one step to input pins of the next", + '- Use format `"stepId.pinName"` for dataFlow endpoints', + "- Steps execute in `next` chain order", + "- Group related flows by domain (e.g., `order-flow`, `payment-flow`)", + "", + "## After Writing", + "", + "Run `forge rehash l4` to fix all contentHash values.", + "Then run `forge prompt scan` to proceed to Phase 3 (L5 blueprint).", + "", + "## Rules", + "", + "- Every L3 block should appear in at least one L4 flow", + "- Flows must have at least one step", + "- dataFlows must reference valid step IDs and pin names from L3 contracts", + '- Write "placeholder" for contentHash — rehash will fix it', + "- Use kebab-case for flow IDs", + ].join("\n") + + languageDirective(input.language ?? "en") + ); +} + +// ── Phase 3: L3 + L4 → L5 ── + +export interface ScanL5Input { + readonly scanContext: ScanContext; + readonly l3Blocks: readonly L3Block[]; + readonly l4Flows: readonly L4Artifact[]; + readonly userIntent?: string; + readonly language?: string; +} + +const L5_SCHEMA_EXAMPLE = `{ + "id": "", + "name": "", + "version": "0.1.0", + "intent": "Core problem + solution approach + success criteria", + "constraints": ["constraint1", "constraint2"], + "domains": [ + { "name": "order", "description": "Order processing domain", "dependencies": ["inventory"] } + ], + "integrations": [ + { "name": "postgres", "type": "database", "description": "Primary database" } + ], + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "..." } +}`; + +export function buildScanL5Prompt(input: ScanL5Input): string { + const { l3Blocks, l4Flows } = input; + + const l4Summary = l4Flows.map((f) => `- **${f.id}** (${f.name})`).join("\n"); + + const l3Summary = l3Blocks.map((b) => `- **${b.id}** (${b.name})`).join("\n"); + + return ( + complexityHeader("standard") + + [ + "# Synthesize L5 Blueprint from L3 Contracts and L4 Flows", + "", + "You are synthesizing the top-level L5 blueprint from existing L3 and L4 artifacts.", + "L5 captures the system's intent, constraints, domains, and integrations.", + "", + ...(input.userIntent === undefined ? [] : ["## System Intent", "", input.userIntent, ""]), + "## Existing L4 Flows", + "", + l4Summary, + "", + "## Existing L3 Contracts", + "", + l3Summary, + "", + "## Instructions", + "", + "From the L3 contracts and L4 flows, synthesize:", + "- **intent**: What does this system do? Core problem + solution + success criteria (1-2 sentences)", + "- **constraints**: Functional, non-functional, and business constraints inferred from the code", + "- **domains**: Bounded contexts derived from L4 flow groupings", + " - Each domain groups related L4 flows", + " - Include dependency relationships between domains", + "- **integrations**: External systems inferred from code patterns", + " - Look for database connections, API calls, message queues, storage", + "", + "Write to `.svp/l5.json` using this schema:", + "", + "```json", + L5_SCHEMA_EXAMPLE, + "```", + "", + "## After Writing", + "", + "Run `forge rehash l5` to fix the contentHash.", + "Then run `forge check` to verify the full SVP structure is consistent.", + "", + "## Rules", + "", + "- Only describe WHAT the system does, not HOW", + "- Keep intent to 1-2 sentences", + "- Constraints are strings, not objects", + "- Domain dependencies reference other domain names", + '- Write "placeholder" for contentHash — rehash will fix it', + ].join("\n") + + languageDirective(input.language ?? "en") + ); +}