import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const output = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M15-03A/next-task-plan.json")); const mapPath = path.join(root, "tests/golden/M15-02A/blender-parity-map.json"); const gapPath = path.join(root, "tests/golden/M15-02B/blender-gap-audit.json"); const map = JSON.parse(fs.readFileSync(mapPath, "utf8")); const gaps = JSON.parse(fs.readFileSync(gapPath, "utf8")); const completionPath = path.join(root, "tests/golden/M15-03A/completed-gap-tasks.json"); const completedIds = fs.existsSync(completionPath) ? new Set(JSON.parse(fs.readFileSync(completionPath, "utf8"))) : new Set(); const byId = new Map(map.entries.map((entry) => [entry.id, entry])); const waveFor = (owner) => { if (["Main", "Mesh", "MODIFIER", "RNA_DATABLOCK", "OPERATOR"].includes(owner)) return "M16"; if (owner === "CONSTRAINT") return "M17"; if (["SHADER_NODE", "GEOMETRY_NODE", "COMPOSITOR_NODE"].includes(owner)) return "M18"; if (owner === "Core") return "M19"; if (["PHYSICS", "SEQUENCER"].includes(owner)) return "M20"; if (["IMPORT_EXPORT", "EDITOR", "REGION", "SPACE", "WORKSPACE", "KEYMAP"].includes(owner)) return "M21"; return "M22"; }; const safeName = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "gap"; const sourceGaps = [...gaps.gaps.unmapped, ...gaps.gaps.summaryOnly, ...gaps.gaps.proxyOnly, ...gaps.gaps.routeOnly]; const uniqueGaps = [...new Set(sourceGaps)]; const work = uniqueGaps.map((gapId) => { const mapping = byId.get(gapId); if (!mapping) throw new Error(`gap has no parity map entry: ${gapId}`); return { gapId, mapping, wave: waveFor(mapping.ownerFamily) }; }); work.sort((a, b) => a.wave.localeCompare(b.wave) || (a.gapId < b.gapId ? -1 : a.gapId > b.gapId ? 1 : 0)); const waveCounts = new Map(); const activeIndex = work.findIndex((value) => !completedIds.has(value.gapId)); const tasks = work.map((value, index) => { const serial = (waveCounts.get(value.wave) ?? 0) + 1; waveCounts.set(value.wave, serial); const id = `${value.wave}-GAP-${String(serial).padStart(5, "0")}`; const fixturePath = `tests/files/web/generated/${id}-${safeName(value.gapId)}.blend`; return { id, gapId: value.gapId, ownerFamily: value.mapping.ownerFamily, sourceTask: value.mapping.task, state: completedIds.has(value.gapId) ? "completed" : index === activeIndex ? "active" : "pending", dependencies: [], targetImplementationClass: "LOCAL_EXACT", fixture: { path: fixturePath, state: "REQUIRED" }, desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${id}.py -- ${fixturePath}`, webCommand: `npm --prefix web run test:generated-gap -- --task ${id}`, comparator: `node tools/web/check-generated-gap.mjs --task ${id}`, exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"], }; }); const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); const completionBytes = fs.readFileSync(completionPath); const plan = { schemaVersion: 1, task: "M15-03A", operation: "BLENDER_NEXT_TASK_PLAN", sources: { parityMap: { path: "tests/golden/M15-02A/blender-parity-map.json", sha256: sha256File(mapPath) }, gapAudit: { path: "tests/golden/M15-02B/blender-gap-audit.json", sha256: sha256File(gapPath) }, completions: { path: "tests/golden/M15-03A/completed-gap-tasks.json", sha256: crypto.createHash("sha256").update(completionBytes).digest("hex") } }, summary: { taskCount: tasks.length, active: tasks.filter((task) => task.state === "active").length, pending: tasks.filter((task) => task.state === "pending").length, completed: tasks.filter((task) => task.state === "completed").length, blocked: tasks.filter((task) => task.state === "blocked").length, byWave: Object.fromEntries([...waveCounts.entries()]) }, tasks, firstTask: tasks.find((task) => task.state === "active")?.id ?? null, closureGate: "M15-03E", nextTask: "M15-03B", }; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, `${JSON.stringify(plan, null, 2)}\n`); process.stdout.write(`blender-next-task-plan-generated tasks=${tasks.length} active=${plan.summary.active} first=${plan.firstTask} output=${output}\n`);