81 lines
4.6 KiB
JavaScript
81 lines
4.6 KiB
JavaScript
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))
|