import {readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync} from 'node:fs'; import {dirname, join} from 'node:path'; import process from 'node:process'; const root = process.cwd(); const sourceDir = join(root, 'data/scope/epics'); const check = process.argv.includes('--check'); const records = readdirSync(sourceDir) .filter((name) => name.endsWith('.yml')) .map((name) => JSON.parse(readFileSync(join(sourceDir, name), 'utf8'))) .sort((left, right) => Number(left.id.split('-').at(-1)) - Number(right.id.split('-').at(-1))); if (records.length !== 10) throw new Error(`Expected 10 Epic records, found ${records.length}`); const ids = new Set(); for (const epic of records) { if (!/^3DARCH-EP-([1-9]|10)$/.test(epic.id)) throw new Error(`Invalid Epic ID: ${epic.id}`); if (ids.has(epic.id)) throw new Error(`Duplicate Epic ID: ${epic.id}`); ids.add(epic.id); if (epic.type !== 'Epic' || epic.status !== 'IN_BACKLOG' || epic.status_source !== 'EXACT') throw new Error(`Invalid governed state: ${epic.id}`); if (epic.approval?.approved_for_solution !== false) throw new Error(`Unexpected solution approval: ${epic.id}`); if (!epic.catalogue_decision?.evidence_url || !epic.last_transition?.evidence_url) throw new Error(`Missing decision evidence: ${epic.id}`); if (!Array.isArray(epic.legacy_evidence) || epic.legacy_evidence.length === 0) throw new Error(`Missing legacy evidence: ${epic.id}`); } const sourceList = (epic) => epic.sources.map((source) => `- [${source.label}](${source.url})`).join('\n'); const evidenceList = (epic) => epic.legacy_evidence.map((source) => `- [\`${source.path}\`](${source.url})`).join('\n'); const epicPage = (epic) => `--- title: "${epic.id} — ${epic.title}" description: "Use this approved Epic boundary to assess related Features and outcomes." --- # ${epic.title} | Epic | Delivery status | Status source | Owner | Approved for Solution | |---|---|---|---|---| | \`${epic.id}\` | \`${epic.status}\` | \`${epic.status_source}\` | ${epic.owner} | No | ## Goal ${epic.goal} ## Benefit ${epic.value} ## Problem or opportunity ${epic.problem} ## Constraints ${epic.constraints.map((item) => `- ${item}`).join('\n')} ## Features No child Features have been defined or approved. Feature discovery must preserve this Epic boundary and requires a separate human **Approved for Solution** decision before Architecture solution work begins. ## Original source ${sourceList(epic)} ### Legacy implementation evidence The approved catalogue was mined from legacy revision \`${epic.legacy_revision}\`. ${evidenceList(epic)} `; const epicLinks = records.map((epic) => `- [${epic.id} — ${epic.title}](./${epic.id.toLowerCase()}.md)`).join('\n'); const epicIndex = `--- title: "Epics" description: "Review the approved experience-led Epic catalogue mined from the legacy platform." --- # Review the approved Epic catalogue These ten Epics define the documented product boundary mined from materially implemented legacy behavior. \`3DARCH-EP-1\` owns the engineering foundation; every remaining Epic describes a user experience or usage outcome. ${epicLinks} Planned-only capabilities—including VR entry, whiteboards, extra Archwiz domains, exports, and undo—remain outside this catalogue until separately evidenced and approved. `; const roadmapRows = records.map((epic) => `| [${epic.id}](./epics/${epic.id.toLowerCase()}.md) | ${epic.title} | \`${epic.status}\` | Current approved catalogue; no delivery forecast | ${epic.owner} | [2026-09-02 decision](${epic.catalogue_decision.evidence_url}) |`).join('\n'); const roadmap = `--- title: "Roadmap" description: "Distinguish the approved Epic baseline from future sequencing and delivery forecasts." --- # Review the product roadmap baseline The current roadmap records the approved Epic catalogue without inventing sequence, dates, Feature scope, or delivery commitments. All Epics remain in \`IN_BACKLOG\`; future horizon and dependency decisions require separate evidence and human approval. | Epic | Outcome area | Delivery status | Horizon | Owner | Last evidenced update | |---|---|---|---|---|---| ${roadmapRows} `; const ts = `// Generated by scripts/generate-scope.mjs from data/scope/epics/*.yml. Do not edit.\nexport type ScopeIssue = {id:string; type:string; title:string; parent:string; status:string; statusSource:string; blocked:boolean; owner:string; approval:string; requiredChildren:string; dependencies:string; evidenceUrl:string; recordUrl:string};\nexport const scopeIssues: ScopeIssue[] = ${JSON.stringify(records.map((epic) => ({ id: epic.id, type: epic.type, title: epic.title, parent: '—', status: epic.status, statusSource: epic.status_source, blocked: epic.blocked, owner: epic.owner, approval: epic.approval.approved_for_solution ? 'Approved for Solution' : 'Not approved for Solution', requiredChildren: epic.required_features.length ? epic.required_features.join(', ') : 'None defined', dependencies: epic.dependencies.length ? epic.dependencies.join(', ') : 'None recorded', evidenceUrl: epic.last_transition.evidence_url, recordUrl: `/scope/epics/${epic.id.toLowerCase()}/`, })), null, 2)};\n`; const expected = new Map([ [join(root, 'scope/epics/index.md'), epicIndex], [join(root, 'scope/roadmap.md'), roadmap], [join(root, 'src/data/scopeIssues.ts'), ts], ...records.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}.md`), epicPage(epic)]), ]); let stale = false; for (const [path, content] of expected) { if (check) { if (!existsSync(path) || readFileSync(path, 'utf8') !== content) { console.error(`Stale generated artifact: ${path}`); stale = true; } } else { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, content); } } if (stale) process.exit(1); console.log(`${check ? 'Verified' : 'Generated'} ${expected.size} Scope artifacts from ${records.length} canonical Epics.`);