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;