210 lines
11 KiB
JavaScript
210 lines
11 KiB
JavaScript
import crypto from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
|
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
|
const catalogPath = path.join(root, "tests/golden/M15-03A/task-catalog.jsonl");
|
|
const indexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
|
const completionPath = path.join(root, "tests/golden/M15-03A/completed-gap-tasks.json");
|
|
const correctivePlanPath = path.join(root, "docs/BLENDER_WASM_CORRECTIVE_TASK_PLAN.json");
|
|
const read = (file) => fs.readFileSync(file, "utf8");
|
|
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
|
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
const json = (file) => JSON.parse(read(file));
|
|
|
|
function arg(name) {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
|
|
const evidenceRootArg = arg("--evidence-root");
|
|
const reportArg = arg("--report");
|
|
const outputRootArg = arg("--output-root");
|
|
const dryRun = process.argv.includes("--dry-run");
|
|
const write = process.argv.includes("--write");
|
|
if (!evidenceRootArg || !reportArg || dryRun === write) {
|
|
throw new Error("usage: node tools/web/reconcile-corrective-state.mjs --evidence-root tests/golden --dry-run|--write --report path [--output-root path]");
|
|
}
|
|
|
|
const evidenceRoot = path.resolve(root, evidenceRootArg);
|
|
const reportPath = path.resolve(root, reportArg);
|
|
const correctiveRoot = path.join(root, "tests/golden/corrective");
|
|
const inside = (file, parent) => file === parent || file.startsWith(`${parent}${path.sep}`);
|
|
if (!inside(reportPath, correctiveRoot)) throw new Error("report must stay under tests/golden/corrective");
|
|
if (write && !outputRootArg) throw new Error("--write requires --output-root");
|
|
const outputRoot = outputRootArg ? path.resolve(root, outputRootArg) : null;
|
|
if (outputRoot && !inside(outputRoot, correctiveRoot)) throw new Error("output-root must stay under tests/golden/corrective");
|
|
|
|
const queue = read(queuePath);
|
|
const queueTask = queue.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
|
const parentManifestPath = queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
|
if (!queueTask || !parentManifestPath) throw new Error("queue must declare current task and parent manifest");
|
|
const parentManifest = json(path.join(root, parentManifestPath));
|
|
const taskPlan = json(planPath);
|
|
const taskToGap = new Map(taskPlan.tasks.map((task) => [task.id, task.gapId]));
|
|
const sourcePaths = [
|
|
queuePath,
|
|
path.join(root, parentManifestPath),
|
|
completionPath,
|
|
planPath,
|
|
catalogPath,
|
|
indexPath,
|
|
correctivePlanPath,
|
|
];
|
|
const sourceHashes = Object.fromEntries(sourcePaths.map((file) => [relative(file), sha256(file)]));
|
|
|
|
const issues = [];
|
|
const issueCounts = new Map();
|
|
const issueTasks = new Map();
|
|
const issuePaths = new Map();
|
|
const addIssue = (issue) => {
|
|
issues.push(issue);
|
|
issueCounts.set(issue.code, (issueCounts.get(issue.code) ?? 0) + 1);
|
|
if (issue.task) {
|
|
const tasks = issueTasks.get(issue.code) ?? new Set();
|
|
tasks.add(issue.task);
|
|
issueTasks.set(issue.code, tasks);
|
|
}
|
|
if (issue.path) {
|
|
const paths = issuePaths.get(issue.code) ?? new Set();
|
|
paths.add(issue.path);
|
|
issuePaths.set(issue.code, paths);
|
|
}
|
|
};
|
|
const records = [];
|
|
const taskDirs = fs.readdirSync(evidenceRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && /^M16-GAP-\d{5}$/u.test(entry.name))
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
for (const directoryName of taskDirs) {
|
|
const directory = path.join(evidenceRoot, directoryName);
|
|
const manifestPath = path.join(directory, "manifest.json");
|
|
let manifest;
|
|
try { manifest = json(manifestPath); } catch (error) {
|
|
addIssue({ code: "MANIFEST_INVALID", task: directoryName, detail: error.message });
|
|
continue;
|
|
}
|
|
const task = manifest.task;
|
|
if (task !== directoryName) addIssue({ code: "TASK_DIRECTORY_MISMATCH", task: directoryName, detail: manifest.task });
|
|
const statusPath = path.join(root, "docs/status", `${task}.md`);
|
|
const contextPath = path.join(directory, "task-context.json");
|
|
const statusExists = fs.existsSync(statusPath);
|
|
const contextExists = fs.existsSync(contextPath);
|
|
if (!statusExists) addIssue({ code: "MISSING_STATUS", task });
|
|
if (!contextExists) addIssue({ code: "MISSING_TASK_CONTEXT", task });
|
|
if (statusExists) {
|
|
const statusText = read(statusPath);
|
|
if (!new RegExp(`status\\s*:\\s*${manifest.status}`, "iu").test(statusText)) {
|
|
addIssue({ code: "STATUS_MISMATCH", task, detail: `manifest=${manifest.status}` });
|
|
}
|
|
}
|
|
if (contextExists) {
|
|
try {
|
|
const context = json(contextPath);
|
|
if (context.task !== task) addIssue({ code: "CONTEXT_TASK_MISMATCH", task, detail: context.task });
|
|
if (context.parentTask !== manifest.parentTask) addIssue({ code: "CONTEXT_PARENT_MISMATCH", task, detail: `${context.parentTask}!=${manifest.parentTask}` });
|
|
} catch (error) {
|
|
addIssue({ code: "CONTEXT_INVALID", task, detail: error.message });
|
|
}
|
|
}
|
|
const artifactIssues = [];
|
|
for (const artifact of Object.values(manifest.artifacts ?? {})) {
|
|
if (!artifact?.path || !artifact?.sha256) {
|
|
artifactIssues.push({ code: "ARTIFACT_DECLARATION_INVALID" });
|
|
continue;
|
|
}
|
|
const artifactPath = path.join(root, artifact.path);
|
|
if (!fs.existsSync(artifactPath)) artifactIssues.push({ code: "ARTIFACT_MISSING", path: artifact.path });
|
|
else if (sha256(artifactPath) !== artifact.sha256) artifactIssues.push({ code: "ARTIFACT_HASH_DRIFT", path: artifact.path });
|
|
}
|
|
for (const issue of artifactIssues) addIssue({ ...issue, task });
|
|
const completeEvidence = manifest.status === "done" && statusExists && contextExists && artifactIssues.length === 0;
|
|
records.push({ task, gapId: taskToGap.get(task) ?? null, parentTask: manifest.parentTask, status: manifest.status, completeEvidence });
|
|
}
|
|
|
|
const active = records.filter((record) => record.status === "in_progress");
|
|
if (active.length !== 1) addIssue({ code: "ACTIVE_TASK_COUNT", detail: `count=${active.length}` });
|
|
if (parentManifest.nextTask !== queueTask) addIssue({ code: "PARENT_NEXT_TASK_MISMATCH", detail: `${parentManifest.nextTask}!=${queueTask}` });
|
|
if (active[0]?.task !== queueTask) addIssue({ code: "ACTIVE_TASK_MISMATCH", detail: `${active[0]?.task ?? "NONE"}!=${queueTask}` });
|
|
|
|
const generatedPlanTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "blender-corrective-plan-"));
|
|
const generatedPlanTemp = path.join(generatedPlanTempRoot, "next-task-plan.json");
|
|
try {
|
|
execFileSync(process.execPath, [path.join(root, "tools/web/generate-blender-next-task-plan.mjs"), generatedPlanTemp], { cwd: root, stdio: "ignore" });
|
|
if (!fs.readFileSync(generatedPlanTemp).equals(fs.readFileSync(planPath))) {
|
|
addIssue({ code: "PLAN_NONDETERMINISTIC", path: relative(planPath), detail: "generator output differs from committed plan" });
|
|
}
|
|
} catch (error) {
|
|
addIssue({ code: "PLAN_GENERATION_FAILED", detail: error.message });
|
|
} finally {
|
|
fs.rmSync(generatedPlanTempRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
const eligibleCompletedTaskIds = records.filter((record) => record.completeEvidence).map((record) => record.task).sort();
|
|
const eligibleCompletedGaps = records.filter((record) => record.completeEvidence && record.gapId).map((record) => record.gapId).sort();
|
|
const registeredCompletedTasks = json(completionPath);
|
|
const eligibleSet = new Set(eligibleCompletedGaps);
|
|
const registeredSet = new Set(registeredCompletedTasks);
|
|
const manifestDoneTasks = records.filter((record) => record.status === "done").map((record) => record.task).sort();
|
|
const manifestDoneGaps = records.filter((record) => record.status === "done" && record.gapId).map((record) => record.gapId).sort();
|
|
const issueSummary = [...issueCounts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, count]) => ({
|
|
code,
|
|
count,
|
|
taskCount: issueTasks.get(code)?.size ?? 0,
|
|
sampleTasks: [...(issueTasks.get(code) ?? [])].sort().slice(0, 5),
|
|
pathCount: issuePaths.get(code)?.size ?? 0,
|
|
samplePaths: [...(issuePaths.get(code) ?? [])].sort().slice(0, 5),
|
|
}));
|
|
const repairPlan = {
|
|
actions: [
|
|
{ code: "MISSING_TASK_CONTEXT", priority: "P1", action: "repair only when the task parent pointer is independently valid; otherwise preserve BLOCKED", writes: ["corrective evidence only"] },
|
|
{ code: "ARTIFACT_HASH_DRIFT", priority: "P0", action: "obtain an immutable source snapshot or classify the task BLOCKED; never rewrite historical manifests", writes: [] },
|
|
{ code: "STATUS_MISMATCH", priority: "P0", action: "compare manifest/status provenance and stop; do not normalize by hand", writes: [] },
|
|
{ code: "PARENT_NEXT_TASK_MISMATCH", priority: "P0", action: "repair through the repository generator only after evidence review", writes: [] },
|
|
{ code: "PLAN_NONDETERMINISTIC", priority: "P0", action: "rebuild generator inputs in staging and compare canonical bytes", writes: ["corrective evidence only"] },
|
|
],
|
|
approvalRequired: true,
|
|
queueMutationAllowed: false,
|
|
};
|
|
const digestInput = JSON.stringify({ sourceHashes, records, issueSummary, eligibleCompletedTaskIds, eligibleCompletedGaps, registeredCompletedTasks });
|
|
const inputDigest = crypto.createHash("sha256").update(digestInput).digest("hex");
|
|
const report = {
|
|
schemaVersion: 1,
|
|
operation: "CORRECTIVE_STATE_RECONCILIATION",
|
|
mode: dryRun ? "dry-run" : "write",
|
|
status: issues.length === 0 ? "PASS" : "BLOCKED",
|
|
queueMutation: false,
|
|
queue: { currentTask: queueTask, parentManifest: parentManifestPath, parentNextTask: parentManifest.nextTask },
|
|
inputTaskCount: records.length,
|
|
manifestDoneCount: manifestDoneTasks.length,
|
|
registeredCompletedCount: registeredCompletedTasks.length,
|
|
eligibleCompletedTaskIds,
|
|
eligibleCompletedGaps,
|
|
registryComparison: {
|
|
eligibleNotRegistered: eligibleCompletedGaps.filter((gap) => !registeredSet.has(gap)),
|
|
registeredNotEligible: registeredCompletedTasks.filter((gap) => !eligibleSet.has(gap)),
|
|
manifestDoneNotEligible: manifestDoneGaps.filter((gap) => !eligibleSet.has(gap)),
|
|
},
|
|
candidateActiveTask: active.length === 1 ? active[0].task : null,
|
|
sourceHashes,
|
|
inputDigest,
|
|
issueSummary,
|
|
issueCount: issues.length,
|
|
issues,
|
|
repairPlan,
|
|
writeOutcome: write ? (issues.length === 0 ? "CANDIDATE_NOT_IMPLEMENTED" : "NO_FILES_WRITTEN") : "DRY_RUN_ONLY",
|
|
};
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
if (write && issues.length === 0) {
|
|
fs.mkdirSync(outputRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(outputRoot, "completion-registry.candidate.json"), `${JSON.stringify({ schemaVersion: 1, tasks: eligibleCompletedGaps }, null, 2)}\n`);
|
|
}
|
|
process.stdout.write(`corrective-reconcile-${report.status.toLowerCase()} mode=${report.mode} tasks=${records.length} eligible=${eligibleCompletedGaps.length} issues=${issues.length} report=${relative(reportPath)}\n`);
|
|
if (issues.length !== 0) process.exitCode = 2;
|