Build and publish 3D Architecture Wizzard documentation / build (pull_request) Successful in 22s
374 lines
18 KiB
JavaScript
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.`);
|