@guanlin4924/devflow-pi 0.1.62 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -36
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +149 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +42 -5
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +62 -787
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +156 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +12 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +14 -10
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/python_ast.py +124 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +11 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/structured_scope.py +121 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -9
- package/src/cli.ts +12 -2
- package/src/constants.ts +1 -1
- package/src/initialization-store.ts +656 -0
- package/src/paths.ts +9 -0
- package/src/project-context.ts +17 -2
- package/src/project-documents.ts +111 -0
- package/src/project-initializer.ts +251 -0
- package/src/project-migration.ts +66 -0
package/src/paths.ts
CHANGED
|
@@ -32,6 +32,15 @@ export function projectSessionDir(projectId: string): string {
|
|
|
32
32
|
return path.join(devflowHome(), "sessions", projectId);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function projectDataDir(projectId: string): string {
|
|
36
|
+
if (!/^[0-9a-f-]{36}$/i.test(projectId)) throw new Error(`Invalid project id: ${projectId}`);
|
|
37
|
+
return path.join(devflowHome(), "projects", projectId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function projectControlDatabasePath(projectId: string): string {
|
|
41
|
+
return path.join(projectDataDir(projectId), "control.db");
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
export function ompSdkManifestPath(): string {
|
|
36
45
|
return fileURLToPath(import.meta.resolve("@oh-my-pi/pi-coding-agent/package.json"));
|
|
37
46
|
}
|
package/src/project-context.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { access, lstat, mkdir, readFile, realpath, readdir, rename, writeFile }
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { PROJECT_SCHEMA_VERSION } from "./constants.ts";
|
|
6
|
+
import { InitializationStore } from "./initialization-store.ts";
|
|
7
|
+
import { projectControlDatabasePath } from "./paths.ts";
|
|
6
8
|
|
|
7
9
|
export interface ProjectManifest {
|
|
8
10
|
schemaVersion: number;
|
|
@@ -16,6 +18,8 @@ export interface ProjectContext {
|
|
|
16
18
|
manifestPath: string;
|
|
17
19
|
manifest: ProjectManifest;
|
|
18
20
|
planningRoot: string;
|
|
21
|
+
workspaceId: string;
|
|
22
|
+
controlDatabasePath: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -132,7 +136,18 @@ export async function loadProjectContext(root: string): Promise<ProjectContext>
|
|
|
132
136
|
const manifestPath = path.join(canonicalRoot, ".devflow", "project.json");
|
|
133
137
|
const manifest = validateManifest(JSON.parse(await readFile(manifestPath, "utf8")), manifestPath);
|
|
134
138
|
const planningRoot = await assertPlanningBoundary(canonicalRoot);
|
|
135
|
-
return { root: canonicalRoot, manifestPath, manifest, planningRoot };
|
|
139
|
+
return attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function attachInitializationStore(context: Omit<ProjectContext, "workspaceId" | "controlDatabasePath">): Promise<ProjectContext> {
|
|
143
|
+
const store = await InitializationStore.open(context);
|
|
144
|
+
try {
|
|
145
|
+
const workspace = await store.registerWorkspace();
|
|
146
|
+
store.putScanConfig({ mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }] });
|
|
147
|
+
return { ...context, workspaceId: workspace.id, controlDatabasePath: projectControlDatabasePath(context.manifest.projectId) };
|
|
148
|
+
} finally {
|
|
149
|
+
store.close();
|
|
150
|
+
}
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
async function atomicWriteJson(target: string, value: unknown): Promise<void> {
|
|
@@ -160,7 +175,7 @@ export async function initializeProject(root: string): Promise<{ context: Projec
|
|
|
160
175
|
};
|
|
161
176
|
await atomicWriteJson(manifestPath, manifest);
|
|
162
177
|
return {
|
|
163
|
-
context: { root: canonicalRoot, manifestPath, manifest, planningRoot },
|
|
178
|
+
context: await attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot }),
|
|
164
179
|
created: true,
|
|
165
180
|
};
|
|
166
181
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { InitializationStore } from "./initialization-store.ts";
|
|
5
|
+
import type { ProjectContext } from "./project-context.ts";
|
|
6
|
+
|
|
7
|
+
export interface LowTierDocumentOrganizer {
|
|
8
|
+
role: string;
|
|
9
|
+
model: string;
|
|
10
|
+
organize(input: { document: string; project: string; knownFacts: string[]; unknowns: string[] }): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DocumentGenerationMode = "low-tier-model" | "template";
|
|
14
|
+
export type DocumentGenerationResult = { targetPath: string; status: "complete" | "conflict" | "failed"; outputPath?: string; error?: string };
|
|
15
|
+
|
|
16
|
+
type Fact = { kind: string; repository: string; objectKey: string; value: Record<string, unknown>; evidenceLevel: string };
|
|
17
|
+
const TEMPLATE_VERSION = "project-context-v2";
|
|
18
|
+
|
|
19
|
+
function markdownCell(value: unknown): string {
|
|
20
|
+
return String(value ?? "未确认").replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
|
|
21
|
+
}
|
|
22
|
+
function sha(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
23
|
+
async function currentHash(target: string): Promise<string | null> {
|
|
24
|
+
try { return sha(await readFile(target, "utf8")); } catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
function safeNarrative(value: string): string {
|
|
27
|
+
const text = value.trim();
|
|
28
|
+
if (!text) return "现有数据不足以确认更多项目用途。";
|
|
29
|
+
if (text.includes("```") || /\b(?:schema_version|snapshotId|runId|sha256:|CREATE TABLE)\b/i.test(text)) throw new Error("Model output contains internal structure or unsupported machine details");
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
function table(headers: string[], rows: unknown[][]): string {
|
|
33
|
+
return [`| ${headers.join(" | ")} |`, `|${headers.map(() => "---").join("|")}|`, ...rows.map(row => `| ${row.map(markdownCell).join(" | ")} |`)].join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function narrative(context: ProjectContext, document: string, facts: Fact[], mode: DocumentGenerationMode, organizer?: LowTierDocumentOrganizer): Promise<{ text: string; modelRole?: string }> {
|
|
37
|
+
const knownFacts = facts.slice(0, 80).map(fact => `${fact.kind}: ${Object.values(fact.value).filter(value => typeof value === "string" || typeof value === "number").slice(0, 3).join(" / ")}`);
|
|
38
|
+
const unknowns = facts.filter(fact => fact.evidenceLevel === "candidate").map(fact => fact.objectKey).slice(0, 20);
|
|
39
|
+
if (mode === "template") return { text: "以下内容由固定项目上下文快照整理。源码静态声明不代表运行环境已启用,未确认项保持未确认。", modelRole: "template" };
|
|
40
|
+
if (!organizer) throw new Error("Low-tier document generation was requested but no organizer is configured");
|
|
41
|
+
if (!/low|small|mini|flash|lite/i.test(`${organizer.role} ${organizer.model}`)) throw new Error("Document organizer is not configured as a low-tier model");
|
|
42
|
+
return { text: safeNarrative(await organizer.organize({ document, project: context.manifest.name, knownFacts, unknowns })), modelRole: `${organizer.role}:${organizer.model}` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderDocuments(context: ProjectContext, facts: Fact[], intro: string, collections: Array<{ repository?: string; kind?: string; status?: string; diagnostics?: unknown[] }>): Array<{ targetPath: string; body: string }> {
|
|
46
|
+
const assets = facts.filter(fact => fact.kind === "asset");
|
|
47
|
+
const technologies = facts.filter(fact => fact.kind === "technology");
|
|
48
|
+
const commands = facts.filter(fact => fact.kind === "toolchain");
|
|
49
|
+
const rules = facts.filter(fact => fact.kind === "rule");
|
|
50
|
+
const capabilities = facts.filter(fact => fact.kind === "capability");
|
|
51
|
+
const repositories = [...new Set(facts.map(fact => fact.repository).filter(Boolean))].sort();
|
|
52
|
+
const outputs: Array<{ targetPath: string; body: string }> = [];
|
|
53
|
+
outputs.push({ targetPath: ".planning/PROJECT.md", body: [
|
|
54
|
+
`# ${context.manifest.name}`, "", intro, "", "## 代码库组成", "",
|
|
55
|
+
table(["代码库", "文件数量", "已观察能力"], repositories.map(repository => [repository, assets.filter(fact => fact.repository === repository).length, [...new Set(facts.filter(fact => fact.repository === repository && ["api", "page", "data_model", "integration"].includes(fact.kind)).map(fact => fact.kind))].join("、") || "未发现"])),
|
|
56
|
+
"", "## 技术概况", "", table(["声明", "来源类型"], technologies.slice(0, 100).map(fact => [fact.value.name ?? fact.value.extension, fact.value.declaration ?? "文件统计"])),
|
|
57
|
+
"", "## 已知边界", "", "当前信息来自本地静态扫描。接口声明不证明服务可访问,页面声明不证明运行时渲染,命令声明不表示已经执行。", "",
|
|
58
|
+
].join("\n") });
|
|
59
|
+
outputs.push({ targetPath: ".planning/toolchain.md", body: ["# 工具链", "", intro, "", table(["代码库", "名称", "命令", "工作目录", "执行状态"], commands.map(fact => [fact.repository, fact.value.name, fact.value.command, fact.value.workingDirectory || ".", "仅发现,未执行"])), ""].join("\n") });
|
|
60
|
+
outputs.push({ targetPath: ".planning/navigation/INDEX.md", body: ["# 项目导航", "", intro, "", table(["代码库", "导航说明", "扫描文件数"], repositories.map(repository => [repository, `.planning/navigation/${repository.replace(/[^\p{L}\p{N}._-]/gu, "-")}.md`, assets.filter(fact => fact.repository === repository).length])), ""].join("\n") });
|
|
61
|
+
for (const repository of repositories) {
|
|
62
|
+
const local = facts.filter(fact => fact.repository === repository);
|
|
63
|
+
outputs.push({ targetPath: `.planning/navigation/${repository.replace(/[^\p{L}\p{N}._-]/gu, "-")}.md`, body: [
|
|
64
|
+
`# ${repository} 导航`, "", intro, "", "## 文件与入口", "",
|
|
65
|
+
table(["路径", "类型"], local.filter(fact => fact.kind === "asset").slice(0, 200).map(fact => [fact.value.path, fact.value.extension || "无扩展名"])),
|
|
66
|
+
"", "## 已发现能力", "", table(["类型", "对象"], local.filter(fact => ["api", "page", "data_model", "integration"].includes(fact.kind)).slice(0, 200).map(fact => [fact.kind, fact.value.path ?? fact.value.route ?? fact.value.name ?? fact.value.host ?? "未确认"])), "",
|
|
67
|
+
].join("\n") });
|
|
68
|
+
}
|
|
69
|
+
outputs.push({ targetPath: ".planning/rules/INDEX.md", body: ["# 项目规则来源", "", intro, "", table(["代码库", "规则文件", "性质"], rules.map(fact => [fact.repository, fact.value.path, fact.value.explicit ? "显式规则" : "惯例候选"])), "", "未登记的惯例候选不会自动成为强制规则。", ""].join("\n") });
|
|
70
|
+
outputs.push({ targetPath: ".planning/initialization/report.md", body: [
|
|
71
|
+
"# 项目初始化报告", "", intro, "", "## 数据可用范围", "",
|
|
72
|
+
table(["采集器", "支持范围", "限制"], capabilities.map(fact => [fact.objectKey, Array.isArray(fact.value.supported) ? fact.value.supported.join("、") : "未确认", Array.isArray(fact.value.limitations) ? fact.value.limitations.join(";") : "未确认"])),
|
|
73
|
+
"", "## 分项采集状态", "", table(["代码库", "专题", "状态", "诊断数"], collections.map(item => [item.repository || "全局", item.kind, item.status, item.diagnostics?.length ?? 0])),
|
|
74
|
+
"", "## 未知项", "", ...((facts.some(fact => fact.evidenceLevel === "candidate") ? facts.filter(fact => fact.evidenceLevel === "candidate").slice(0, 30).map(fact => `- ${markdownCell(fact.objectKey)}:候选,尚未核对`) : ["未记录候选未知项。"])), "",
|
|
75
|
+
].join("\n") });
|
|
76
|
+
return outputs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function generateProjectDocuments(context: ProjectContext, snapshotId: string, options: { mode: DocumentGenerationMode; organizer?: LowTierDocumentOrganizer }): Promise<DocumentGenerationResult[]> {
|
|
80
|
+
const store = await InitializationStore.open(context);
|
|
81
|
+
try {
|
|
82
|
+
const facts: Fact[] = []; let after: string | undefined; let runId: string | undefined;
|
|
83
|
+
do {
|
|
84
|
+
const page = store.queryContext({ snapshotId, limit: 500, after }) as { snapshot: { runId: string }; facts: Fact[]; truncated: boolean; nextCursor: string | null };
|
|
85
|
+
runId = page.snapshot.runId;
|
|
86
|
+
facts.push(...page.facts); after = page.nextCursor ?? undefined;
|
|
87
|
+
if (facts.length > 50_000) throw new Error("Document manifest exceeds the bounded 50,000 fact limit");
|
|
88
|
+
} while (after);
|
|
89
|
+
const run = runId ? store.getRun(runId) as { collections?: Array<{ repository?: string; kind?: string; status?: string; diagnostics?: unknown[] }> } | null : null;
|
|
90
|
+
const collections = run?.collections ?? [];
|
|
91
|
+
const batchJob = store.createDocumentJob(snapshotId, { targetPath: ".planning/initialization/report.md", ownership: "generated", templateVersion: TEMPLATE_VERSION, modelRole: options.organizer ? `${options.organizer.role}:${options.organizer.model}` : options.mode, input: facts.map(fact => fact.objectKey) });
|
|
92
|
+
let organized;
|
|
93
|
+
try { organized = await narrative(context, "project-initialization", facts, options.mode, options.organizer); }
|
|
94
|
+
catch (error) { store.failDocumentJob(batchJob, error instanceof Error ? error.message : String(error)); throw error; }
|
|
95
|
+
const outputs = renderDocuments(context, facts, organized.text, collections); const results: DocumentGenerationResult[] = [];
|
|
96
|
+
for (const output of outputs) {
|
|
97
|
+
const job = store.createDocumentJob(snapshotId, { targetPath: output.targetPath, ownership: "generated", templateVersion: TEMPLATE_VERSION, modelRole: organized.modelRole, input: facts.map(fact => fact.objectKey) });
|
|
98
|
+
try {
|
|
99
|
+
const knownHash = store.latestDocumentHash(output.targetPath);
|
|
100
|
+
const existingHash = await currentHash(`${context.root}/${output.targetPath}`);
|
|
101
|
+
const expected = existingHash && !knownHash ? "unmanaged-existing-file" : knownHash ?? undefined;
|
|
102
|
+
const written = await store.writeDocument(job, output.body, { expectedCurrentHash: expected });
|
|
103
|
+
results.push({ targetPath: output.targetPath, status: written.status, outputPath: written.path });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error); store.failDocumentJob(job, message);
|
|
106
|
+
results.push({ targetPath: output.targetPath, status: "failed", error: message });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return results;
|
|
110
|
+
} finally { store.close(); }
|
|
111
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { FACT_KINDS, InitializationStore, type FactInput, type FactKind, type ScanConfigInput } from "./initialization-store.ts";
|
|
6
|
+
import type { ProjectContext } from "./project-context.ts";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_EXCLUDED = new Set([".git", ".devflow", ".planning", "node_modules", "dist", "build", "coverage", "target", "vendor", ".next", ".nuxt"]);
|
|
9
|
+
const TEXT_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".rb", ".go", ".java", ".kt", ".rs", ".php", ".sql", ".json", ".yaml", ".yml", ".toml", ".md"]);
|
|
10
|
+
|
|
11
|
+
type SourceFile = { repository: string; path: string; absolute: string; contentHash: string; size: number; text?: string };
|
|
12
|
+
type ScanDiagnostic = { repository: string; path: string; reason: "symlink" | "excluded" | "too_large" | "unreadable"; message?: string };
|
|
13
|
+
export type InitializationProgress = { runId: string; snapshotId?: string; completeness?: "complete" | "partial"; diagnostics: string[]; documents?: Array<{ targetPath: string; status: string; outputPath?: string; error?: string }> };
|
|
14
|
+
|
|
15
|
+
function digest(value: string | Uint8Array): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
16
|
+
function extension(file: string): string { return path.extname(file).toLowerCase(); }
|
|
17
|
+
function globMatches(value: string, pattern: string): boolean {
|
|
18
|
+
const compile = (candidate: string) => candidate.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "\0").replaceAll("*", "[^/]*").replaceAll("\0", ".*");
|
|
19
|
+
return new RegExp(`^${compile(pattern)}$`).test(value)
|
|
20
|
+
|| (pattern.startsWith("**/") && new RegExp(`^${compile(pattern.slice(3))}$`).test(value));
|
|
21
|
+
}
|
|
22
|
+
function redactCommand(command: unknown): string {
|
|
23
|
+
if (typeof command !== "string") return "";
|
|
24
|
+
return command
|
|
25
|
+
.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)[A-Z0-9_]*)=([^\s]+)/gi, "$1=<redacted>")
|
|
26
|
+
.replace(/(--?(?:token|secret|password|passwd|api[-_]?key))(?:=|\s+)([^\s]+)/gi, "$1=<redacted>")
|
|
27
|
+
.replace(/(authorization\s*[:=]\s*bearer\s+)([^\s]+)/gi, "$1<redacted>")
|
|
28
|
+
.replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1<redacted>@");
|
|
29
|
+
}
|
|
30
|
+
function source(file: SourceFile, kind: FactKind, objectKey: string, value: Record<string, unknown>, extractor: string, line?: number): FactInput {
|
|
31
|
+
return { kind, objectKey, repository: file.repository, value, evidenceLevel: "observed", sourcePath: file.path, sourceLine: line, sourceFingerprint: file.contentHash, extractor, confidence: "heuristic" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function walk(repository: string, repositoryRoot: string, includes: string[], excludes: string[], diagnostics: ScanDiagnostic[], relative = ""): Promise<SourceFile[]> {
|
|
35
|
+
const directory = path.join(repositoryRoot, relative);
|
|
36
|
+
let entries;
|
|
37
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
38
|
+
catch (error) { diagnostics.push({ repository, path: relative || ".", reason: "unreadable", message: error instanceof Error ? error.message : String(error) }); return []; }
|
|
39
|
+
const result: SourceFile[] = [];
|
|
40
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
41
|
+
if (entry.isSymbolicLink()) { diagnostics.push({ repository, path: path.join(relative, entry.name), reason: "symlink" }); continue; }
|
|
42
|
+
if (DEFAULT_EXCLUDED.has(entry.name)) continue;
|
|
43
|
+
const child = path.join(relative, entry.name);
|
|
44
|
+
const normalized = child.replaceAll("\\", "/");
|
|
45
|
+
if (excludes.some(pattern => globMatches(normalized, pattern) || globMatches(`${normalized}/`, pattern))) { if (diagnostics.length < 1000) diagnostics.push({ repository, path: normalized, reason: "excluded" }); continue; }
|
|
46
|
+
if (entry.isDirectory()) { result.push(...await walk(repository, repositoryRoot, includes, excludes, diagnostics, child)); continue; }
|
|
47
|
+
if (!entry.isFile()) continue;
|
|
48
|
+
if (includes.length && !includes.some(pattern => globMatches(normalized, pattern))) continue;
|
|
49
|
+
const absolute = path.join(repositoryRoot, child); const info = await stat(absolute);
|
|
50
|
+
if (info.size > 2 * 1024 * 1024) { diagnostics.push({ repository, path: normalized, reason: "too_large" }); continue; }
|
|
51
|
+
let bytes: Buffer;
|
|
52
|
+
try { bytes = await readFile(absolute); }
|
|
53
|
+
catch (error) { diagnostics.push({ repository, path: normalized, reason: "unreadable", message: error instanceof Error ? error.message : String(error) }); continue; }
|
|
54
|
+
result.push({ repository, path: child.replaceAll("\\", "/"), absolute, size: info.size, contentHash: digest(bytes), text: TEXT_EXTENSIONS.has(extension(child)) ? bytes.toString("utf8") : undefined });
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function gitRevision(root: string): Promise<string | null> {
|
|
60
|
+
const child = Bun.spawn(["git", "-C", root, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "ignore" });
|
|
61
|
+
if (await child.exited !== 0) return null;
|
|
62
|
+
const revision = (await new Response(child.stdout).text()).trim();
|
|
63
|
+
return /^[0-9a-f]{40,64}$/i.test(revision) ? revision : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function scan(context: ProjectContext, config: ScanConfigInput): Promise<{ files: SourceFile[]; diagnostics: ScanDiagnostic[]; repositoryVersions: Array<{ repository: string; revision: string | null }> }> {
|
|
67
|
+
const all: SourceFile[] = [];
|
|
68
|
+
const diagnostics: ScanDiagnostic[] = [];
|
|
69
|
+
const repositoryVersions: Array<{ repository: string; revision: string | null }> = [];
|
|
70
|
+
const includes = config.includes ?? ["**/*"];
|
|
71
|
+
const excludes = config.excludes ?? [".git/**", ".planning/**", ".devflow/**"];
|
|
72
|
+
for (const repository of config.repositories) {
|
|
73
|
+
const root = path.resolve(context.root, repository.relativePath);
|
|
74
|
+
if (!path.isAbsolute(repository.relativePath) && root !== context.root && !root.startsWith(`${context.root}${path.sep}`)) throw new Error(`Repository escapes project root: ${repository.relativePath}`);
|
|
75
|
+
repositoryVersions.push({ repository: repository.name, revision: await gitRevision(root) });
|
|
76
|
+
all.push(...await walk(repository.name, root, includes, excludes, diagnostics));
|
|
77
|
+
}
|
|
78
|
+
return { files: all.sort((a, b) => `${a.repository}/${a.path}`.localeCompare(`${b.repository}/${b.path}`)), diagnostics, repositoryVersions };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function frozenManifest(scanResult: Awaited<ReturnType<typeof scan>>): Array<{ repository: string; path: string; contentHash: string; size?: number }> {
|
|
82
|
+
return [
|
|
83
|
+
...scanResult.files.map(({ repository, path: sourcePath, contentHash, size }) => ({ repository, path: sourcePath, contentHash, size })),
|
|
84
|
+
...scanResult.repositoryVersions.filter(item => item.revision).map(item => ({ repository: item.repository, path: "@source/git-head", contentHash: digest(item.revision!), size: item.revision!.length })),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function lineMatches(file: SourceFile, expression: RegExp): Array<{ line: number; match: RegExpMatchArray }> {
|
|
89
|
+
return (file.text ?? "").split(/\r?\n/).flatMap((text, index) => {
|
|
90
|
+
const match = text.match(expression); return match ? [{ line: index + 1, match }] : [];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function collect(files: SourceFile[], context: ProjectContext, config: ScanConfigInput): Map<FactKind, FactInput[]> {
|
|
95
|
+
const facts = new Map<FactKind, FactInput[]>(FACT_KINDS.map(kind => [kind, []]));
|
|
96
|
+
const extensions = new Map<string, number>();
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
facts.get("asset")!.push(source(file, "asset", `file:${file.repository}:${file.path}`, { path: file.path, repository: file.repository, size: file.size, extension: extension(file.path) || null }, "filesystem@1"));
|
|
99
|
+
const extensionKey = `${file.repository}\0${extension(file.path) || "none"}`;
|
|
100
|
+
extensions.set(extensionKey, (extensions.get(extensionKey) ?? 0) + 1);
|
|
101
|
+
for (const { line, match } of lineMatches(file, /\b(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*["'`]([^"'`]+)/i)) {
|
|
102
|
+
facts.get("api")!.push(source(file, "api", `route:${match[1].toUpperCase()}:${match[2]}`, { method: match[1].toUpperCase(), path: match[2], runtimeVerified: false }, "static-route@1", line));
|
|
103
|
+
}
|
|
104
|
+
for (const { line, match } of lineMatches(file, /@(Get|Post|Put|Patch|Delete)Mapping\s*\(\s*["'`]([^"'`]+)/i)) {
|
|
105
|
+
facts.get("api")!.push(source(file, "api", `route:${match[1].toUpperCase()}:${match[2]}`, { method: match[1].toUpperCase(), path: match[2], framework: "spring", runtimeVerified: false }, "static-route@1", line));
|
|
106
|
+
}
|
|
107
|
+
for (const { line, match } of lineMatches(file, /^\s*(get|post|put|patch|delete)\s+["'`]([^"'`]+)/i)) {
|
|
108
|
+
facts.get("api")!.push(source(file, "api", `route:${match[1].toUpperCase()}:${match[2]}`, { method: match[1].toUpperCase(), path: match[2], framework: "rails", runtimeVerified: false }, "static-route@1", line));
|
|
109
|
+
}
|
|
110
|
+
for (const { line, match } of lineMatches(file, /\bHandleFunc\s*\(\s*["'`]([^"'`]+)/)) {
|
|
111
|
+
facts.get("api")!.push(source(file, "api", `route:ANY:${match[1]}`, { method: null, path: match[1], framework: "go-http", runtimeVerified: false }, "static-route@1", line));
|
|
112
|
+
}
|
|
113
|
+
for (const { line, match } of lineMatches(file, /(?:path\s*[:=]|<Route[^>]+path=)\s*["'`]([^"'`]+)["'`]/i)) {
|
|
114
|
+
facts.get("page")!.push(source(file, "page", `page:${match[1]}`, { route: match[1], renderedVerified: false }, "static-page@1", line));
|
|
115
|
+
}
|
|
116
|
+
const role = config.repositories.find(item => item.name === file.repository)?.role;
|
|
117
|
+
const pageMatch = file.path.match(/(?:^|\/)(?:pages|app)\/(.+?)\.(?:tsx?|jsx?|vue)$/i);
|
|
118
|
+
if (role === "frontend" && pageMatch) facts.get("page")!.push(source(file, "page", `page-file:${file.repository}:${file.path}`, { route: `/${pageMatch[1].replace(/\/index$/, "").replace(/\[(.+?)\]/g, ":$1")}`, componentPath: file.path, renderedVerified: false }, "filesystem-page@1"));
|
|
119
|
+
for (const { line, match } of lineMatches(file, /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?([A-Za-z_][\w.]*)/i)) {
|
|
120
|
+
facts.get("data_model")!.push(source(file, "data_model", `model:${match[1]}`, { name: match[1], sourceType: extension(file.path) === ".sql" ? "migration" : "code" }, "static-model@1", line));
|
|
121
|
+
}
|
|
122
|
+
for (const { line, match } of lineMatches(file, /^\s*class\s+([A-Za-z_]\w*)\s*(?:extends\s+(?:Model|Entity)|\([^)]*(?:Model|Entity|Base)[^)]*\))/i)) {
|
|
123
|
+
facts.get("data_model")!.push(source(file, "data_model", `model:${file.repository}:${match[1]}`, { name: match[1], sourceType: "code" }, "static-model@1", line));
|
|
124
|
+
}
|
|
125
|
+
for (const { line, match } of lineMatches(file, /https?:\/\/([^\s/"'`]+)/i)) {
|
|
126
|
+
const host = match[1].replace(/^.*@/, "").toLowerCase();
|
|
127
|
+
facts.get("integration")!.push(source(file, "integration", `host:${host}`, { host, enabledInEnvironment: null }, "static-integration@1", line));
|
|
128
|
+
}
|
|
129
|
+
for (const { line, match } of lineMatches(file, /\b(?:cron|schedule|subscribe|consumer)\s*\(/i)) {
|
|
130
|
+
facts.get("integration")!.push(source(file, "integration", `task:${file.repository}:${file.path}:${line}`, { kind: match[0].split("(")[0].trim().toLowerCase(), enabledInEnvironment: null }, "static-task@1", line));
|
|
131
|
+
}
|
|
132
|
+
for (const { line, match } of lineMatches(file, /(?:process\.env\.|os\.getenv\s*\(\s*["']|ENV\s*\[\s*["']|System\.getenv\s*\(\s*["'])([A-Za-z_][A-Za-z0-9_]*)/)) {
|
|
133
|
+
facts.get("integration")!.push(source(file, "integration", `config:${file.repository}:${match[1]}`, { name: match[1], valueStored: false, enabledInEnvironment: null }, "static-config@1", line));
|
|
134
|
+
}
|
|
135
|
+
if (/(^|\/)(AGENTS|CLAUDE|CONTRIBUTING)\.md$/i.test(file.path) || /(^|\/)\.cursorrules$/i.test(file.path)) {
|
|
136
|
+
facts.get("rule")!.push(source(file, "rule", `rule:${file.repository}:${file.path}`, { path: file.path, scope: file.repository, explicit: true }, "rule-file@1"));
|
|
137
|
+
}
|
|
138
|
+
if (path.basename(file.path) === "package.json" && file.text) {
|
|
139
|
+
try {
|
|
140
|
+
const manifest = JSON.parse(file.text) as Record<string, any>;
|
|
141
|
+
for (const group of ["dependencies", "devDependencies", "peerDependencies"]) for (const name of Object.keys(manifest[group] ?? {})) {
|
|
142
|
+
facts.get("technology")!.push(source(file, "technology", `dependency:${file.repository}:${name}`, { name, declaration: group, locked: false }, "package-json@1"));
|
|
143
|
+
}
|
|
144
|
+
for (const [name, command] of Object.entries(manifest.scripts ?? {})) facts.get("toolchain")!.push(source(file, "toolchain", `command:${file.repository}:${name}`, { name, command: redactCommand(command), workingDirectory: path.dirname(file.path), executed: false }, "package-json@1"));
|
|
145
|
+
} catch { /* diagnostics are attached by the caller's collection status */ }
|
|
146
|
+
}
|
|
147
|
+
if (/^(?:package-lock\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|Gemfile\.lock)$/i.test(path.basename(file.path))) facts.get("technology")!.push(source(file, "technology", `lockfile:${file.repository}:${file.path}`, { path: file.path, locked: true }, "lockfile@1"));
|
|
148
|
+
if (/(?:^|\/)(?:test|tests|spec|__tests__)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file.path)) facts.get("toolchain")!.push(source(file, "toolchain", `test-definition:${file.repository}:${file.path}`, { testDefinition: file.path, executed: false }, "test-discovery@1"));
|
|
149
|
+
}
|
|
150
|
+
for (const [key, count] of extensions) {
|
|
151
|
+
const [repository, ext] = key.split("\0");
|
|
152
|
+
facts.get("technology")!.push({ kind: "technology", repository, objectKey: `language-extension:${ext}`, value: { extension: ext, fileCount: count }, evidenceLevel: "candidate", extractor: "extension-summary@1", confidence: "heuristic" });
|
|
153
|
+
}
|
|
154
|
+
for (const repository of config.repositories) {
|
|
155
|
+
facts.get("evidence")!.push({ kind: "evidence", repository: repository.name, objectKey: "input-policy", value: { source: "local-static", discoveredCommandsExecuted: false, gitMetadataRead: true, secretsStored: false }, evidenceLevel: "explicit_rule", extractor: "initializer@1", confidence: "exact" });
|
|
156
|
+
facts.get("capability")!.push({ kind: "capability", repository: repository.name, objectKey: "collector:static@1", value: { supported: ["asset", "technology", "api", "page", "data_model", "integration", "rule", "toolchain"], limitations: ["static declarations only", "dynamic behavior unresolved", "no environment enablement inference"], project: context.manifest.name }, evidenceLevel: "explicit_rule", extractor: "initializer@1", confidence: "exact" });
|
|
157
|
+
}
|
|
158
|
+
return facts;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function runProcess(argv: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
|
|
162
|
+
const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
163
|
+
const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
164
|
+
if (exitCode !== 0) throw new Error(`${path.basename(argv[1] ?? argv[0])} failed (${exitCode}): ${(stderr || stdout).trim()}`);
|
|
165
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput): Promise<Record<string, unknown>> {
|
|
169
|
+
const script = path.resolve(import.meta.dir, "../plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py");
|
|
170
|
+
const output = ".planning/code-index";
|
|
171
|
+
const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
|
|
172
|
+
await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root);
|
|
173
|
+
await runProcess(["python3", script, "validate", "--index", output], context.root);
|
|
174
|
+
await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills")], context.root);
|
|
175
|
+
const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
|
|
176
|
+
const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
177
|
+
if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");
|
|
178
|
+
return { path: output, generation: snapshot.generation, immutablePath: snapshot.immutablePath, snapshotHash: digest(bytes), status: "complete" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function runProjectInitialization(context: ProjectContext, input: { config?: ScanConfigInput; requestId?: string; documentMode?: "low-tier-model" | "template" | "none"; organizer?: import("./project-documents.ts").LowTierDocumentOrganizer; switchAuthority?: boolean } = {}): Promise<InitializationProgress> {
|
|
182
|
+
const config: ScanConfigInput = input.config ?? { mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }], requiredKinds: [...FACT_KINDS], adapterVersions: { static: "1" } };
|
|
183
|
+
const store = await InitializationStore.open(context); const diagnostics: string[] = [];
|
|
184
|
+
let activeRun: { id: string; leaseToken: string } | undefined;
|
|
185
|
+
try {
|
|
186
|
+
const workspace = await store.registerWorkspace(); const storedConfig = store.putScanConfig(config);
|
|
187
|
+
const initialScan = await scan(context, config); const files = initialScan.files;
|
|
188
|
+
const manifest = frozenManifest(initialScan);
|
|
189
|
+
const inputSnapshot = store.createInputSnapshot(workspace.id, storedConfig.id, manifest, false);
|
|
190
|
+
const run = store.startRun({ workspaceId: workspace.id, configId: storedConfig.id, inputSnapshotId: inputSnapshot.id, requestId: input.requestId });
|
|
191
|
+
activeRun = run;
|
|
192
|
+
if (run.reused) return { runId: run.id, snapshotId: store.getRun(run.id)?.publishedSnapshotId as string | undefined, diagnostics };
|
|
193
|
+
const grouped = collect(files, context, config); const required = new Set(config.requiredKinds ?? FACT_KINDS);
|
|
194
|
+
for (const repository of config.repositories) for (const kind of FACT_KINDS) {
|
|
195
|
+
const repositoryFacts = grouped.get(kind)!.filter(fact => fact.repository === repository.name);
|
|
196
|
+
const requiredKind = required.has(kind);
|
|
197
|
+
const applicable = kind === "page"
|
|
198
|
+
? repository.role === "frontend" || repository.role === "other"
|
|
199
|
+
: kind === "api" || kind === "data_model"
|
|
200
|
+
? repository.role === "backend" || repository.role === "other"
|
|
201
|
+
: true;
|
|
202
|
+
const repositoryDiagnostics = initialScan.diagnostics.filter(item => item.repository === repository.name);
|
|
203
|
+
const hasBlockingScanFailure = repositoryDiagnostics.some(item => item.reason === "unreadable");
|
|
204
|
+
const status = !requiredKind
|
|
205
|
+
? "skipped"
|
|
206
|
+
: !applicable
|
|
207
|
+
? "not_applicable"
|
|
208
|
+
: hasBlockingScanFailure
|
|
209
|
+
? "partial"
|
|
210
|
+
: "complete";
|
|
211
|
+
store.recordCollection(run.id, run.leaseToken, {
|
|
212
|
+
repository: repository.name,
|
|
213
|
+
kind,
|
|
214
|
+
status,
|
|
215
|
+
adapter: "static@1",
|
|
216
|
+
capability: { static: true, applicable, completeAbsenceIsObserved: status === "complete" && repositoryFacts.length === 0, scanDiagnostics: repositoryDiagnostics.length },
|
|
217
|
+
diagnostics: repositoryDiagnostics,
|
|
218
|
+
facts: status === "complete" ? repositoryFacts : [],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const indexRef = config.mode === "full" ? await buildCodeIndex(context, config) : { status: "skipped", reason: "explicit_fast_mode" };
|
|
222
|
+
const after = await scan(context, config);
|
|
223
|
+
const afterManifest = frozenManifest(after);
|
|
224
|
+
const verified = store.createInputSnapshot(workspace.id, storedConfig.id, afterManifest, false);
|
|
225
|
+
if (verified.fingerprint !== inputSnapshot.fingerprint) throw new Error("Source changed during initialization; refusing to publish mixed input");
|
|
226
|
+
store.confirmInputSnapshot(inputSnapshot.id, inputSnapshot.fingerprint);
|
|
227
|
+
const published = store.publishRun(run.id, run.leaseToken, { expectedRevision: undefined, indexRef });
|
|
228
|
+
if (input.switchAuthority !== false) {
|
|
229
|
+
const authority = store.getAuthority();
|
|
230
|
+
if (authority.authority === "legacy") store.switchAuthority("database", authority.revision);
|
|
231
|
+
}
|
|
232
|
+
const result = store.queryContext({ snapshotId: published.snapshotId, limit: 1 }) as any;
|
|
233
|
+
const documentMode = input.documentMode ?? "template";
|
|
234
|
+
let documents: InitializationProgress["documents"];
|
|
235
|
+
if (documentMode !== "none") {
|
|
236
|
+
try {
|
|
237
|
+
documents = await (await import("./project-documents.ts")).generateProjectDocuments(context, published.snapshotId, { mode: documentMode, organizer: input.organizer });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
240
|
+
diagnostics.push(`Document generation failed independently: ${message}`);
|
|
241
|
+
documents = [{ targetPath: ".planning/initialization/report.md", status: "failed", error: message }];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { runId: run.id, snapshotId: published.snapshotId, completeness: result.snapshot.completeness, diagnostics, documents };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
247
|
+
diagnostics.push(message);
|
|
248
|
+
if (activeRun) store.failRun(activeRun.id, activeRun.leaseToken, message);
|
|
249
|
+
throw error;
|
|
250
|
+
} finally { store.close(); }
|
|
251
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { InitializationStore } from "./initialization-store.ts";
|
|
6
|
+
import type { ProjectContext } from "./project-context.ts";
|
|
7
|
+
|
|
8
|
+
function hash(content: Uint8Array | string): string { return `sha256:${createHash("sha256").update(content).digest("hex")}`; }
|
|
9
|
+
function relativeSource(context: ProjectContext, source: string): string {
|
|
10
|
+
const absolute = path.resolve(context.root, source); const relative = path.relative(context.root, absolute).replaceAll("\\", "/");
|
|
11
|
+
if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) throw new Error(`Legacy source escapes project root: ${source}`);
|
|
12
|
+
return relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function importLegacyInitialization(context: ProjectContext, sources: string[], requestId = `legacy-import-${randomUUID()}`): Promise<{ runId: string; snapshotId: string; imports: string[] }> {
|
|
16
|
+
if (!sources.length) throw new Error("At least one legacy source is required");
|
|
17
|
+
const documents = await Promise.all(sources.map(async source => {
|
|
18
|
+
const relative = relativeSource(context, source); const content = await readFile(path.join(context.root, relative));
|
|
19
|
+
return { relative, contentHash: hash(content), size: content.byteLength };
|
|
20
|
+
}));
|
|
21
|
+
const store = await InitializationStore.open(context);
|
|
22
|
+
try {
|
|
23
|
+
const workspace = await store.registerWorkspace();
|
|
24
|
+
const config = store.putScanConfig({ mode: "fast", repositories: [{ name: "legacy-materials", relativePath: ".", role: "other" }], requiredKinds: ["evidence"], adapterVersions: { "legacy-import": "1" } });
|
|
25
|
+
const input = store.createInputSnapshot(workspace.id, config.id, documents.map(item => ({ repository: "legacy-materials", path: item.relative, contentHash: item.contentHash, size: item.size })), true);
|
|
26
|
+
const run = store.startRun({ workspaceId: workspace.id, configId: config.id, inputSnapshotId: input.id, requestId });
|
|
27
|
+
if (!run.reused) store.recordCollection(run.id, run.leaseToken, {
|
|
28
|
+
kind: "evidence", status: "complete", adapter: "legacy-import@1",
|
|
29
|
+
facts: documents.map(item => ({ kind: "evidence" as const, objectKey: `historical:${item.relative}`, value: { sourcePath: item.relative, contentHash: item.contentHash, materialType: path.extname(item.relative).slice(1) || "text", importedAs: "historical", observed: false, approved: false }, evidenceLevel: "historical" as const, sourcePath: item.relative, sourceFingerprint: item.contentHash, extractor: "legacy-import@1", confidence: "exact" as const })),
|
|
30
|
+
});
|
|
31
|
+
const snapshot = run.reused && store.getRun(run.id)?.publishedSnapshotId
|
|
32
|
+
? { snapshotId: String(store.getRun(run.id)!.publishedSnapshotId) }
|
|
33
|
+
: store.publishRun(run.id, run.leaseToken, { completeness: "partial", updateCurrent: false });
|
|
34
|
+
const imports = documents.map(item => store.registerHistoricalImport(workspace.id, item.relative, item.contentHash, snapshot.snapshotId));
|
|
35
|
+
const authority = store.getAuthority();
|
|
36
|
+
if (authority.authority === "legacy") store.switchAuthority("shadow", authority.revision);
|
|
37
|
+
return { runId: run.id, snapshotId: snapshot.snapshotId, imports };
|
|
38
|
+
} finally { store.close(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function exportInitializationSnapshot(context: ProjectContext, snapshotId: string, targetPath: string): Promise<{ path: string; contentHash: string }> {
|
|
42
|
+
const absolute = path.resolve(context.root, targetPath);
|
|
43
|
+
if (absolute !== context.root && !absolute.startsWith(`${context.root}${path.sep}`)) throw new Error("Export path escapes project root");
|
|
44
|
+
const store = await InitializationStore.open(context);
|
|
45
|
+
try {
|
|
46
|
+
const facts: unknown[] = []; let after: string | undefined; let snapshot: unknown;
|
|
47
|
+
do {
|
|
48
|
+
const page = store.queryContext({ snapshotId, after, limit: 500 }) as any; snapshot ??= page.snapshot; facts.push(...page.facts); after = page.nextCursor ?? undefined;
|
|
49
|
+
if (facts.length > 100_000) throw new Error("Snapshot export exceeds 100,000 facts");
|
|
50
|
+
} while (after);
|
|
51
|
+
const content = `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), snapshot, facts }, null, 2)}\n`;
|
|
52
|
+
await mkdir(path.dirname(absolute), { recursive: true }); const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
53
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx" }); await rename(temporary, absolute);
|
|
54
|
+
return { path: absolute, contentHash: hash(content) };
|
|
55
|
+
} finally { store.close(); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function switchInitializationAuthority(context: ProjectContext, authority: "legacy" | "shadow" | "database", expectedRevision: number, options: { compatibilityExport?: string } = {}): Promise<{ authority: string; revision: number }> {
|
|
59
|
+
const store = await InitializationStore.open(context);
|
|
60
|
+
try {
|
|
61
|
+
const current = store.getAuthority();
|
|
62
|
+
if (current.authority === "database" && authority === "legacy" && !options.compatibilityExport) throw new Error("Rollback to legacy authority requires a verified compatibility export");
|
|
63
|
+
if (options.compatibilityExport) await readFile(path.resolve(context.root, options.compatibilityExport));
|
|
64
|
+
return store.switchAuthority(authority, expectedRevision);
|
|
65
|
+
} finally { store.close(); }
|
|
66
|
+
}
|