import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' const root = resolve(new URL('..', import.meta.url).pathname) const plan = JSON.parse(await readFile(resolve(root, 'config/freecad-execution-plan.json'), 'utf8')) const fail = (message) => { throw new Error(`FreeCAD execution plan: ${message}`) } const requireText = (value, label) => { if (typeof value !== 'string' || !value.trim()) fail(`${label} must be a non-empty string.`) } const requireTextList = (value, label) => { if (!Array.isArray(value) || value.length === 0) fail(`${label} must be a non-empty array.`) value.forEach((entry, index) => requireText(entry, `${label}[${index}]`)) } const uniqueIds = (items, label) => { if (!Array.isArray(items) || items.length === 0) fail(`${label} must be a non-empty array.`) const ids = new Set() for (const item of items) { requireText(item?.id, `${label} id`) if (ids.has(item.id)) fail(`duplicate ${label} id '${item.id}'.`) ids.add(item.id) } return ids } if (plan.schemaVersion !== 1) fail(`unsupported schemaVersion '${plan.schemaVersion}'.`) if (plan.baseline?.freecadVersion !== '1.1.1' || plan.baseline?.freecadCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') fail('baseline does not match the locked FreeCAD reference.') const laneIds = uniqueIds(plan.lanes, 'lane') const gateIds = uniqueIds(plan.gates, 'gate') const programIds = uniqueIds(plan.programs, 'program') const requiredPrograms = Array.from({ length: 10 }, (_, index) => `P${String(index + 1).padStart(2, '0')}`) if (requiredPrograms.some((id) => !programIds.has(id)) || plan.programs.length !== requiredPrograms.length) fail('programs must contain exactly P01 through P10.') const taskById = new Map() const taskProgram = new Map() const statuses = new Set(['pending', 'in_progress', 'completed', 'blocked']) for (const program of plan.programs) { requireText(program.title, `${program.id}.title`) if (!laneIds.has(program.ownerLane)) fail(`${program.id} uses unknown owner lane '${program.ownerLane}'.`) if (!gateIds.has(program.gate)) fail(`${program.id} uses unknown gate '${program.gate}'.`) if (!Array.isArray(program.dependencies)) fail(`${program.id}.dependencies must be an array.`) for (const dependency of program.dependencies) if (!programIds.has(dependency)) fail(`${program.id} depends on unknown program '${dependency}'.`) if (!Array.isArray(program.tasks) || program.tasks.length === 0) fail(`${program.id} must contain tasks.`) for (const task of program.tasks) { requireText(task.id, `${program.id} task id`) if (taskById.has(task.id)) fail(`duplicate task id '${task.id}'.`) requireText(task.title, `${task.id}.title`) if (!statuses.has(task.status)) fail(`${task.id} has invalid status '${task.status}'.`) if (!Array.isArray(task.dependencies)) fail(`${task.id}.dependencies must be an array.`) requireTextList(task.deliverables, `${task.id}.deliverables`) requireTextList(task.acceptance, `${task.id}.acceptance`) taskById.set(task.id, task) taskProgram.set(task.id, program.id) } } for (const task of taskById.values()) for (const dependency of task.dependencies) if (!taskById.has(dependency)) fail(`${task.id} depends on unknown task '${dependency}'.`) const visitState = new Map() const visit = (taskId, path = []) => { const state = visitState.get(taskId) if (state === 'done') return if (state === 'visiting') fail(`task dependency cycle: ${[...path, taskId].join(' -> ')}.`) visitState.set(taskId, 'visiting') for (const dependency of taskById.get(taskId).dependencies) visit(dependency, [...path, taskId]) visitState.set(taskId, 'done') } for (const taskId of taskById.keys()) visit(taskId) for (const gate of plan.gates) { requireText(gate.title, `${gate.id}.title`) requireTextList(gate.requiredPrograms, `${gate.id}.requiredPrograms`) for (const programId of gate.requiredPrograms) if (!programIds.has(programId)) fail(`${gate.id} references unknown program '${programId}'.`) } for (const program of plan.programs) { const gate = plan.gates.find((candidate) => candidate.id === program.gate) if (!gate.requiredPrograms.includes(program.id)) fail(`${program.gate} does not require ${program.id}.`) } requireTextList(plan.rules, 'rules') const statusCounts = Object.fromEntries([...statuses].map((status) => [status, [...taskById.values()].filter((task) => task.status === status).length])) console.log(JSON.stringify({ status: 'execution-plan-pass', programs: plan.programs.length, lanes: plan.lanes.length, gates: plan.gates.length, tasks: taskById.size, statusCounts, taskPrograms: new Set(taskProgram.values()).size }, null, 2))