#!/usr/bin/env node
/**
* Validate an overview artifact JSON document and render a self-contained HTML file.
* The renderer never calls Gitea; callers provide a JSON snapshot from the extractors.
*/
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
const SCHEMA_VERSION = 1;
const WORK_TYPES = new Set(['implementation', 'data', 'architecture', 'schedule', 'documentation']);
const DIAGRAM_KINDS = new Set(['flowchart', 'dependency', 'architecture', 'network', 'repo', 'schedule']);
export function validateOverview(document) {
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('SCHEMA_INVALID', '根資料必須是物件');
if (document.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_UNSUPPORTED', `只支援 schemaVersion ${SCHEMA_VERSION}`);
if (!document.source?.requirement?.repo || !Number.isInteger(document.source.requirement.index)) {
fail('SCHEMA_INVALID', 'source.requirement 必須包含 repo 與整數 index');
}
if (!document.requirement || typeof document.requirement !== 'object') fail('SCHEMA_INVALID', '缺少 requirement');
if (!Array.isArray(document.workPackages)) fail('SCHEMA_INVALID', 'workPackages 必須是陣列');
for (const [position, workPackage] of document.workPackages.entries()) validateWorkPackage(workPackage, position);
validateDiagrams(document.requirement.diagrams, 'requirement');
return document;
}
function validateWorkPackage(value, position) {
if (!value || typeof value !== 'object') fail('SCHEMA_INVALID', `workPackages[${position}] 必須是物件`);
for (const key of ['title', 'description', 'scope', 'issue']) {
if (typeof value[key] !== 'string' || value[key].trim() === '') fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必填`);
}
if (!WORK_TYPES.has(value.type)) fail('SCHEMA_INVALID', `workPackages[${position}].type 不支援:${value.type}`);
for (const key of ['repos', 'depends', 'todos', 'acceptance']) {
if (!Array.isArray(value[key])) fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必須是陣列`);
}
if (value.interfaces !== undefined) {
if (!Array.isArray(value.interfaces)) fail('SCHEMA_INVALID', `workPackages[${position}].interfaces 必須是陣列`);
for (const contract of value.interfaces) validateInterface(contract, position);
}
validateDiagrams(value.diagrams, `workPackages[${position}]`);
if (value.formulas !== undefined && !Array.isArray(value.formulas)) fail('SCHEMA_INVALID', `workPackages[${position}].formulas 必須是陣列`);
}
function validateInterface(contract, position) {
const kinds = {
http: ['method', 'path', 'request', 'response', 'errors', 'example'],
cli: ['command', 'args', 'stdout', 'stderr', 'exitCodes', 'example'],
function: ['signature', 'input', 'output', 'errors', 'example'],
event: ['topic', 'payload', 'producer', 'consumer', 'delivery', 'errors', 'example'],
storage: ['operation', 'entity', 'schema', 'constraints', 'transaction', 'example'],
};
if (!kinds[contract?.interfaceType]) fail('SCHEMA_INVALID', `workPackages[${position}] 有未知 interfaceType`);
for (const key of kinds[contract.interfaceType]) {
if (contract[key] === undefined) fail('SCHEMA_INVALID', `介面契約缺少 ${contract.interfaceType}.${key}`);
}
}
function validateDiagrams(diagrams, owner) {
if (diagrams === undefined) return;
if (!Array.isArray(diagrams)) fail('SCHEMA_INVALID', `${owner}.diagrams 必須是陣列`);
for (const diagram of diagrams) {
if (!DIAGRAM_KINDS.has(diagram?.kind)) fail('DIAGRAM_UNSUPPORTED', `${owner} 有未知圖表類型`);
if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) fail('SCHEMA_INVALID', `${owner} 圖表必須有 nodes 與 edges`);
const ids = new Set(diagram.nodes.map((node) => node.id));
for (const edge of diagram.edges) if (!ids.has(edge.from) || !ids.has(edge.to)) fail('SCHEMA_INVALID', `${owner} 圖表有不存在的邊端點`);
}
}
export function renderOverview(document) {
validateOverview(document);
const requirement = document.requirement;
const packages = document.workPackages;
const diagrams = [...(requirement.diagrams ?? []), ...packages.flatMap((workPackage) => workPackage.diagrams ?? [])];
const html = `
${escapeHtml(requirement.title ?? '需求總覽')}${escapeHtml(requirement.summary ?? '')}
${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}工作包
${packages.map(renderWorkPackage).join('')}${renderFormulas(requirement.formulas)}`;
return html;
}
export function renderFallbackSvg(document) {
validateOverview(document);
const diagrams = [...(document.requirement.diagrams ?? []), ...document.workPackages.flatMap((workPackage) => workPackage.diagrams ?? [])];
const height = 180 + diagrams.length * 300;
const diagramMarkup = diagrams.map((diagram, index) => `${renderSvgContents(diagram)}`).join('');
return ``;
}
function renderWorkPackage(workPackage, index) {
const slug = slugify(workPackage.title) || `work-package-${index + 1}`;
const done = workPackage.todos.filter((todo) => todo.done).length;
return `${escapeHtml(workPackage.title)}
${escapeHtml(workPackage.description)}
- 類型
- ${escapeHtml(workPackage.type)}
- 進度
- ${done}/${workPackage.todos.length} 項待辦
- 依賴
- ${escapeHtml(workPackage.depends.join(', ') || '無')}
- repo
- ${escapeHtml(workPackage.repos.join(', ') || '無')}
查看工作包詳細內容
`;
}
function renderDiagrams(diagrams) {
return diagrams.map((diagram, index) => `${escapeHtml(diagram.title ?? diagram.kind)}
${renderSvg(diagram)}
${diagram.omitted ? `未產生:${escapeHtml(diagram.omitted)}
` : ''}`).join('');
}
function renderSvg(diagram) {
const columns = Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length || 1)));
const width = Math.max(640, columns * 220);
const height = Math.max(180, Math.ceil((diagram.nodes.length || 1) / columns) * 100 + 80);
const positions = new Map(diagram.nodes.map((node, index) => [node.id, { x: 30 + (index % columns) * 210, y: 35 + Math.floor(index / columns) * 100 }]));
const edges = diagram.edges.map((edge) => { const from = positions.get(edge.from); const to = positions.get(edge.to); return from && to ? `${escapeHtml(edge.label ?? '')}` : ''; }).join('');
const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `${escapeHtml(node.label ?? node.id)}`; }).join('');
return ``;
}
function renderSvgContents(diagram) {
return renderSvg(diagram).replace(/^