docs: publish legacy feature catalogue
Build and publish 3D Architecture Wizzard documentation / build (pull_request) Successful in 22s

This commit is contained in:
2026-09-03 08:14:14 +00:00
parent 94ffd5d4cf
commit 3a2e19fc52
139 changed files with 8681 additions and 203 deletions
+276 -42
View File
@@ -1,32 +1,98 @@
import {readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync} from 'node:fs';
import {dirname, join} from 'node:path';
import {dirname, join, relative} from 'node:path';
import process from 'node:process';
const root = process.cwd();
const sourceDir = join(root, 'data/scope/epics');
const epicSourceDir = join(root, 'data/scope/epics');
const featureSourceDir = join(root, 'data/scope/features');
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)));
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));
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}`);
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 decision evidence: ${epic.id}`);
if (!Array.isArray(epic.legacy_evidence) || epic.legacy_evidence.length === 0) throw new Error(`Missing legacy evidence: ${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 = (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 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: "Use this approved Epic boundary to assess related Features and outcomes."
description: "Review the governed outcome and child Features for ${epic.id}."
---
# ${epic.title}
@@ -49,11 +115,13 @@ ${epic.problem}
## Constraints
${epic.constraints.map((item) => `- ${item}`).join('\n')}
${bulletList(epic.constraints)}
## 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.
${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
@@ -61,66 +129,232 @@ ${sourceList(epic)}
### Legacy implementation evidence
The approved catalogue was mined from legacy revision \`${epic.legacy_revision}\`.
The catalogue was mined from legacy revision \`${epic.legacy_revision}\`.
${evidenceList(epic)}
`;
const featuresPage = (epic) => `---
title: "${epic.id} Features"
description: "Review Features governed by ${epic.id}."
description: "Review the governed Features beneath ${epic.id}."
---
# Review ${epic.id} Features
No child Features have been defined or approved for this Epic. Feature discovery must preserve the [${epic.id} boundary](../${epic.id.toLowerCase()}.md) and requires a separate human **Approved for Solution** decision before Architecture solution work begins.
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 epicLinks = records.map((epic) => `- [${epic.id} — ${epic.title}](./${epic.id.toLowerCase()}.md)`).join('\n');
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 mined from the legacy platform."
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. \`3DARCH-EP-1\` owns the engineering foundation; every remaining Epic describes a user experience or usage outcome.
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 = 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 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 approved Epic baseline from future sequencing and delivery forecasts."
description: "Distinguish the documented Epic and Feature 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.
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.
| Epic | Outcome area | Delivery status | Horizon | Owner | Last evidenced update |
|---|---|---|---|---|---|
| Type | Record | Parent | 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 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],
...records.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}.md`), epicPage(epic)]),
...records.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}/features.md`), featuresPage(epic)]),
[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;
@@ -136,4 +370,4 @@ for (const [path, content] of expected) {
}
}
if (stale) process.exit(1);
console.log(`${check ? 'Verified' : 'Generated'} ${expected.size} Scope artifacts from ${records.length} canonical Epics.`);
console.log(`${check ? 'Verified' : 'Generated'} ${expected.size} Scope artifacts from ${epics.length} canonical Epics and ${features.length} canonical Features.`);