Files
corp-v1-3darch-documentation/scripts/generate-scope.mjs
jarvis-at-skic 3a2e19fc52
Build and publish 3D Architecture Wizzard documentation / build (pull_request) Successful in 22s
docs: publish legacy feature catalogue
2026-09-03 08:14:14 +00:00

374 lines
18 KiB
JavaScript

import {readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
import process from 'node:process';
const root = process.cwd();
const epicSourceDir = join(root, 'data/scope/epics');
const featureSourceDir = join(root, 'data/scope/features');
const check = process.argv.includes('--check');
const numericId = (id) => Number(id.split('-').at(-1));
const readRecords = (directory, recordPattern) => readdirSync(directory)
.filter((name) => recordPattern.test(name))
.map((name) => JSON.parse(readFileSync(join(directory, name), 'utf8')))
.sort((left, right) => numericId(left.id) - numericId(right.id));
const epics = readRecords(epicSourceDir, /^3darch-ep-\d+\.yml$/);
const features = readRecords(featureSourceDir, /^3darch-ft-\d+\.yml$/);
if (epics.length !== 10) throw new Error(`Expected 10 Epic records, found ${epics.length}`);
if (features.length !== 32) throw new Error(`Expected 32 Feature records, found ${features.length}`);
const epicIds = new Set();
const featureIds = new Set();
for (const [index, epic] of epics.entries()) {
const expectedId = `3DARCH-EP-${index + 1}`;
if (epic.id !== expectedId) throw new Error(`Expected ${expectedId}, found ${epic.id}`);
if (epicIds.has(epic.id)) throw new Error(`Duplicate Epic ID: ${epic.id}`);
epicIds.add(epic.id);
if (epic.type !== 'Epic' || epic.status_source !== 'DERIVED') throw new Error(`Invalid governed Epic 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 Epic decision evidence: ${epic.id}`);
if (!Array.isArray(epic.legacy_evidence) || epic.legacy_evidence.length === 0) throw new Error(`Missing Epic legacy evidence: ${epic.id}`);
}
for (const [index, feature] of features.entries()) {
const expectedId = `3DARCH-FT-${index + 1}`;
if (feature.id !== expectedId) throw new Error(`Expected ${expectedId}, found ${feature.id}`);
if (featureIds.has(feature.id)) throw new Error(`Duplicate Feature ID: ${feature.id}`);
featureIds.add(feature.id);
if (feature.type !== 'Feature' || feature.status !== 'IN_BACKLOG' || feature.status_source !== 'EXACT') throw new Error(`Invalid governed Feature state: ${feature.id}`);
if (!epicIds.has(feature.epic_id)) throw new Error(`Dangling Epic relationship: ${feature.id} -> ${feature.epic_id}`);
if (feature.approval?.approved_for_solution !== false) throw new Error(`Unexpected solution approval: ${feature.id}`);
for (const field of ['description', 'user_problem', 'expected_value', 'scope', 'owner']) if (!feature[field]) throw new Error(`Missing ${field}: ${feature.id}`);
for (const field of ['exclusions', 'acceptance_outcomes', 'risks', 'legacy_evidence']) if (!Array.isArray(feature[field]) || feature[field].length === 0) throw new Error(`Missing ${field}: ${feature.id}`);
if (feature.acceptance_outcomes.length < 3) throw new Error(`Too few acceptance outcomes: ${feature.id}`);
if (!Array.isArray(feature.dependencies) || !Array.isArray(feature.required_tasks)) throw new Error(`Invalid relationships: ${feature.id}`);
if (!feature.catalogue_decision?.evidence_url || feature.catalogue_decision.decision !== 'Approved for documentation') throw new Error(`Missing Feature documentation decision: ${feature.id}`);
if (!feature.last_transition?.evidence_url || feature.last_transition.event !== 'Catalogue registration' || feature.last_transition.to !== feature.status || !feature.last_transition.authority || !feature.last_transition.reason) throw new Error(`Missing distinct Feature lifecycle evidence: ${feature.id}`);
}
for (const feature of features) {
for (const dependency of feature.dependencies) {
if (!featureIds.has(dependency)) throw new Error(`Dangling Feature dependency: ${feature.id} -> ${dependency}`);
if (dependency === feature.id) throw new Error(`Self dependency: ${feature.id}`);
}
}
const visiting = new Set();
const visited = new Set();
const featureById = new Map(features.map((feature) => [feature.id, feature]));
const visit = (id) => {
if (visiting.has(id)) throw new Error(`Feature dependency cycle at ${id}`);
if (visited.has(id)) return;
visiting.add(id);
for (const dependency of featureById.get(id).dependencies) visit(dependency);
visiting.delete(id);
visited.add(id);
};
for (const feature of features) visit(feature.id);
const featuresByEpic = new Map(epics.map((epic) => [epic.id, features.filter((feature) => feature.epic_id === epic.id)]));
for (const epic of epics) {
const childIds = featuresByEpic.get(epic.id).map((feature) => feature.id);
if (JSON.stringify(epic.required_features) !== JSON.stringify(childIds)) throw new Error(`One-sided or unsorted Feature relationship: ${epic.id}`);
const statuses = featuresByEpic.get(epic.id).map((feature) => feature.status);
const derived = statuses.every((status) => status === 'DONE') ? 'DONE'
: statuses.every((status) => ['TO_BE_RELEASED', 'DONE'].includes(status)) ? 'TO_BE_RELEASED'
: statuses.some((status) => ['IN_DELIVERY', 'TO_BE_RELEASED', 'DONE'].includes(status)) ? 'IN_DELIVERY'
: statuses.some((status) => status === 'IN_DESIGN') ? 'IN_DESIGN' : 'IN_BACKLOG';
if (epic.status !== derived) throw new Error(`Derived status mismatch: ${epic.id} is ${epic.status}, expected ${derived}`);
}
const sourceList = (record) => record.sources.map((source) => `- [${source.label}](${source.url})`).join('\n');
const evidenceList = (record) => record.legacy_evidence.map((source) => `- [\`${source.path}\`](${source.url})`).join('\n');
const bulletList = (items) => items.map((item) => `- ${item}`).join('\n');
const featureRoute = (feature) => `/scope/epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}/`;
const epicRoute = (epic) => `/scope/epics/${epic.id.toLowerCase()}/`;
const relativeFeatureLink = (fromFeature, targetFeature) => {
const fromDir = join('scope/epics', fromFeature.epic_id.toLowerCase(), 'features');
const target = join('scope/epics', targetFeature.epic_id.toLowerCase(), 'features', `${targetFeature.id.toLowerCase()}.md`);
const result = relative(fromDir, target).replaceAll('\\', '/');
return result.startsWith('.') ? result : `./${result}`;
};
const featureLinkList = (epic, prefix) => featuresByEpic.get(epic.id)
.map((feature) => `- [${feature.id} — ${feature.title}](${prefix}${feature.id.toLowerCase()}.md)`)
.join('\n');
const epicPage = (epic) => `---
title: "${epic.id} — ${epic.title}"
description: "Review the governed outcome and child Features for ${epic.id}."
---
# ${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
${bulletList(epic.constraints)}
## Features
${featureLinkList(epic, `./${epic.id.toLowerCase()}/features/`)}
These Features are approved for documentation only. Each remains outside Architecture solution work until a separate human **Approved for Solution** decision is recorded.
## Original source
${sourceList(epic)}
### Legacy implementation evidence
The catalogue was mined from legacy revision \`${epic.legacy_revision}\`.
${evidenceList(epic)}
`;
const featuresPage = (epic) => `---
title: "${epic.id} Features"
description: "Review the governed Features beneath ${epic.id}."
---
# Review ${epic.id} Features
These Features decompose the [${epic.id} boundary](../${epic.id.toLowerCase()}.md) into non-overlapping user and operator outcomes grounded in the frozen legacy revision.
${featureLinkList(epic, './features/')}
Documentation approval does not grant **Approved for Solution** or implementation approval.
`;
const featurePage = (feature) => {
const epic = epics.find((item) => item.id === feature.epic_id);
const dependencyLines = feature.dependencies.length
? feature.dependencies.map((id) => {
const target = featureById.get(id);
return `- [${target.id} — ${target.title}](${relativeFeatureLink(feature, target)})`;
}).join('\n')
: '- None recorded.';
return `---
title: "${feature.id} — ${feature.title}"
description: "${feature.description.replaceAll('"', '\\"')}"
---
# ${feature.title}
| Feature | Parent Epic | Delivery status | Status source | Owner | Approved for Solution |
|---|---|---|---|---|---|
| \`${feature.id}\` | [${epic.id} — ${epic.title}](../../${epic.id.toLowerCase()}.md) | \`${feature.status}\` | \`${feature.status_source}\` | ${feature.owner} | No |
## Description
${feature.description}
## User or operator problem
${feature.user_problem}
## Expected value
${feature.expected_value}
## Scope
${feature.scope}
### Exclusions
${bulletList(feature.exclusions)}
## Acceptance outcomes
${feature.acceptance_outcomes.map((item) => `- [ ] ${item}`).join('\n')}
## Dependencies
${dependencyLines}
## Risks
${bulletList(feature.risks)}
## Human approval
Linas approved this Feature for documentation on ${feature.catalogue_decision.decided_at}. It is **not Approved for Solution** and has no implementation approval.
- [Documentation decision](${feature.catalogue_decision.evidence_url})
## Delivery status evidence
The Feature was separately registered at \`${feature.last_transition.to}\` as an accepted catalogue boundary. This records backlog retention, not design or delivery progress.
- Event: ${feature.last_transition.event}
- Actor and authority: ${feature.last_transition.actor} — ${feature.last_transition.authority}
- Reason: ${feature.last_transition.reason}
- [Status evidence](${feature.last_transition.evidence_url})
## Architecture traceability
- Requirements: none derived.
- Readiness: ${feature.architecture_traceability.readiness}.
Architecture solution work must not begin until a separate human **Approved for Solution** decision is recorded.
## Tasks
[Review the Tasks group](./${feature.id.toLowerCase()}/tasks.md). No canonical implementation Tasks have been defined.
## Original source
${sourceList(feature)}
### Legacy implementation evidence
The Feature boundary was mined from legacy revision \`${feature.legacy_revision}\`.
${evidenceList(feature)}
`;
};
const tasksPage = (feature) => `---
title: "${feature.id} Tasks"
description: "Review canonical implementation Tasks governed beneath ${feature.id}."
---
# Review ${feature.id} Tasks
No canonical implementation Tasks have been defined for [${feature.id}](../${feature.id.toLowerCase()}.md). Architecture must first receive a separate human **Approved for Solution** decision, derive requirements and readiness evidence, and then register any resulting Tasks through Scope governance.
`;
const epicLinks = epics.map((epic) => `- [${epic.id} — ${epic.title}](./${epic.id.toLowerCase()}.md)`).join('\n');
const epicIndex = `---
title: "Epics"
description: "Review the approved experience-led Epic catalogue and its documented Features."
---
# Review the approved Epic catalogue
These ten Epics define the documented product boundary mined from materially implemented legacy behavior. Their 32 child Features provide the approved documentation-level decomposition without granting solution or implementation approval.
${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 = epics.map((epic) => `| Epic | [${epic.id}](./epics/${epic.id.toLowerCase()}.md) | — | ${epic.title} | \`${epic.status}\` | Current documented catalogue; no delivery forecast | ${epic.owner} | [2026-09-02 evidence](${epic.catalogue_decision.evidence_url}) |`).concat(
features.map((feature) => `| Feature | [${feature.id}](./epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}.md) | [${feature.epic_id}](./epics/${feature.epic_id.toLowerCase()}.md) | ${feature.title} | \`${feature.status}\` | Current documented catalogue; no delivery forecast | ${feature.owner} | [2026-09-02 evidence](${feature.catalogue_decision.evidence_url}) |`),
).join('\n');
const roadmap = `---
title: "Roadmap"
description: "Distinguish the documented Epic and Feature baseline from future sequencing and delivery forecasts."
---
# Review the product roadmap baseline
The current roadmap records the documented Epic and Feature catalogue without inventing dates or delivery commitments. All Epics and Features remain in \`IN_BACKLOG\`; Feature dependencies are recorded on each canonical Feature page, while sequencing and horizon decisions require separate evidence.
| Type | Record | Parent | Outcome area | Delivery status | Horizon | Owner | Last evidenced update |
|---|---|---|---|---|---|---|---|
${roadmapRows}
`;
const linkFor = (id) => {
if (epicIds.has(id)) {
const epic = epics.find((item) => item.id === id);
return {id, title: epic.title, recordUrl: epicRoute(epic)};
}
const feature = featureById.get(id);
return {id, title: feature.title, recordUrl: featureRoute(feature)};
};
const issues = [
...epics.map((epic) => ({
id: epic.id, type: epic.type, title: epic.title, parent: null, status: epic.status,
statusSource: epic.status_source, blocked: epic.blocked, blockedReason: epic.blocked_reason, owner: epic.owner,
approval: epic.approval.approved_for_solution ? 'Approved for Solution' : 'Not approved for Solution',
requiredChildren: epic.required_features.map(linkFor), dependencies: epic.dependencies.map(linkFor),
releaseEvidence: epic.release_evidence ?? [], evidenceUrl: epic.last_transition.evidence_url, recordUrl: epicRoute(epic),
})),
...features.map((feature) => ({
id: feature.id, type: feature.type, title: feature.title, parent: linkFor(feature.epic_id), status: feature.status,
statusSource: feature.status_source, blocked: feature.blocked, blockedReason: feature.blocked_reason, owner: feature.owner,
approval: feature.approval.approved_for_solution ? 'Approved for Solution' : 'Not approved for Solution',
requiredChildren: feature.required_tasks.map(linkFor), dependencies: feature.dependencies.map(linkFor),
releaseEvidence: feature.release_evidence, evidenceUrl: feature.last_transition.evidence_url, recordUrl: featureRoute(feature),
})),
];
const ts = `// Generated by scripts/generate-scope.mjs from canonical Scope records. Do not edit.\nexport type ScopeIssueLink = {id:string; title:string; recordUrl:string};\nexport type ScopeIssue = {id:string; type:string; title:string; parent:ScopeIssueLink|null; status:string; statusSource:string; blocked:boolean; blockedReason:string|null; owner:string; approval:string; requiredChildren:ScopeIssueLink[]; dependencies:ScopeIssueLink[]; releaseEvidence:unknown[]; evidenceUrl:string; recordUrl:string};\nexport const scopeIssues: ScopeIssue[] = ${JSON.stringify(issues, null, 2)};\n`;
const sidebarItems = epics.map((epic) => ` {
type: 'category' as const,
label: '${epic.id}',
link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}'},
items: [{
type: 'category' as const,
label: 'Features',
link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}/features'},
items: [
${featuresByEpic.get(epic.id).map((feature) => ` {
type: 'category' as const,
label: '${feature.id}',
link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}/features/${feature.id.toLowerCase()}'},
items: [{type: 'category' as const, label: 'Tasks', link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}/features/${feature.id.toLowerCase()}/tasks'}, items: []}],
}`).join(',\n')}
],
}],
}`).join(',\n');
const sidebar = `import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
// Generated by scripts/generate-scope.mjs from canonical Scope records. Do not edit.
const sidebars: SidebarsConfig = {
scopeSidebar: [
'overview',
'roadmap',
{type: 'category', label: 'Ideas', link: {type: 'doc', id: 'ideas'}, items: []},
{type: 'category', label: 'Questions', link: {type: 'doc', id: 'questions'}, items: []},
{
type: 'category',
label: 'Epics',
link: {type: 'doc', id: 'epics/index'},
collapsed: false,
items: [
${sidebarItems}
],
},
],
};
export default sidebars;
`;
const expected = new Map([
[join(root, 'scope/epics/index.md'), epicIndex],
[join(root, 'scope/roadmap.md'), roadmap],
[join(root, 'src/data/scopeIssues.ts'), ts],
[join(root, 'sidebarsScope.ts'), sidebar],
...epics.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}.md`), epicPage(epic)]),
...epics.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}/features.md`), featuresPage(epic)]),
...features.map((feature) => [join(root, `scope/epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}.md`), featurePage(feature)]),
...features.map((feature) => [join(root, `scope/epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}/tasks.md`), tasksPage(feature)]),
]);
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 ${epics.length} canonical Epics and ${features.length} canonical Features.`);