export const STATUSES = ['design', 'in-progress', 'shipped', 'superseded', 'abandoned', 'unknown']; const DATE_RE = /^(\d{4}-\d{2}-\d{2})-/; export function docParts(relPath) { const archived = relPath.startsWith('archived/'); const type = /(^|\/)specs\//.test(relPath) ? 'spec' : 'plan'; const file = relPath.split('/').pop(); const base = file.replace(/\.md$/, ''); const m = base.match(DATE_RE); return { type, date: m ? m[1] : null, base, archived }; } export function deriveTopicGuess(relPath) { const { type, base } = docParts(relPath); let s = base.replace(DATE_RE, ''); // spec のみ、末尾がちょうど -design / -plan のときだけ除去(redesign を守る) if (type === 'spec') s = s.replace(/-(design|plan)$/, ''); return s; } export function extractTitle(mdText) { const line = mdText.split('\n').find((l) => l.startsWith('# ')); if (!line) return ''; let t = line.slice(2).trim(); t = t.replace(/\s*(—\s*設計書|実装計画|設計書)\s*$/, '').trim(); return t; } export function hasSupersededBy(v) { return typeof v === 'string' && v.trim() !== ''; } export function scaffoldEntryObject(relPath, mdText) { const { type, archived } = docParts(relPath); const topic = deriveTopicGuess(relPath); const title = extractTitle(mdText) || topic; return { path: relPath, title, type, topic, status: archived ? 'shipped' : 'unknown', pr: [], superseded_by: null }; } export function findUnregistered(existingRelPaths, manifestPaths) { return existingRelPaths.filter((p) => !manifestPaths.has(p)); } const REQUIRED = ['path', 'title', 'type', 'topic', 'status']; export function validateManifest(entries, existingRelPaths) { const errors = []; const warnings = []; const manifestPaths = new Set(entries.map((e) => e.path)); const existing = new Set(existingRelPaths); for (const e of entries) { for (const k of REQUIRED) { if (e[k] === undefined || e[k] === null || e[k] === '') errors.push(`${e.path ?? '(no path)'}: missing required field '${k}'`); } if (e.status && !STATUSES.includes(e.status)) errors.push(`${e.path}: invalid status '${e.status}'`); if (e.type && e.type !== 'spec' && e.type !== 'plan') errors.push(`${e.path}: invalid type '${e.type}'`); if (e.path && !existing.has(e.path)) errors.push(`${e.path}: manifest entry does not exist on disk (欠落)`); if (hasSupersededBy(e.superseded_by) && !manifestPaths.has(e.superseded_by)) errors.push(`${e.path}: superseded_by '${e.superseded_by}' not in manifest`); if (hasSupersededBy(e.superseded_by) && e.status !== 'superseded') warnings.push(`${e.path}: has superseded_by but status is '${e.status}' (expected superseded)`); if (e.status === 'superseded' && !hasSupersededBy(e.superseded_by)) warnings.push(`${e.path}: status superseded but superseded_by empty`); } for (const p of existingRelPaths) { if (!manifestPaths.has(p)) errors.push(`${p}: file exists but is not registered in manifest (孤児)`); } const unknownCount = entries.filter((e) => e.status === 'unknown').length; if (unknownCount > 0) warnings.push(`${unknownCount} entries still have status 'unknown' (curation pending)`); return { errors, warnings }; } // ─── renderIndex and helpers ────────────────────────────────────────────────── export const PR_BASE = 'https://gitea.example.com/your-org/maestro/pulls/'; export const BADGE = { design: '🔵', 'in-progress': '🟡', shipped: '🟢', superseded: '⚪', abandoned: '⚫', unknown: '❔', }; export function escapeCell(s) { return String(s ?? '') .replace(/\r?\n/g, ' ') .replace(/\|/g, '\\|') .replace(/\[/g, '[') .replace(/\]/g, ']'); } export function prUrl(n) { return PR_BASE + n; } function row(e) { const { date } = docParts(e.path); const link = `[${escapeCell(e.title)}](./${e.path})`; const prs = (e.pr ?? []).map((n) => `[#${n}](${prUrl(n)})`).join(' '); const succ = hasSupersededBy(e.superseded_by) ? `[→](./${e.superseded_by})` : ''; return `| ${BADGE[e.status] ?? '❔'} | ${e.type} | ${link} | ${date ?? ''} | ${prs} | ${succ} |`; } function groupSection(title, entries) { const byTopic = new Map(); for (const e of entries) { const k = e.topic && e.topic.trim() ? e.topic : '(未分類)'; if (!byTopic.has(k)) byTopic.set(k, []); byTopic.get(k).push(e); } const topics = [...byTopic.keys()].sort(); let out = `\n## ${title}\n`; for (const t of topics) { const rows = byTopic .get(t) .slice() .sort((a, b) => { const d = (docParts(a.path).date ?? '').localeCompare(docParts(b.path).date ?? ''); return d !== 0 ? d : a.path.localeCompare(b.path); }); out += `\n### ${escapeCell(t)}\n\n| 段階 | 種別 | 資料 | 日付 | PR | 後継 |\n|---|---|---|---|---|---|\n`; out += rows.map(row).join('\n') + '\n'; } return out; } export function renderIndex(entries) { const active = entries.filter((e) => !docParts(e.path).archived); const archived = entries.filter((e) => docParts(e.path).archived); const counts = STATUSES.map((s) => `${BADGE[s]} ${entries.filter((e) => e.status === s).length}`).join(' ・ '); let out = `# 設計資料インデックス\n\n> 生成物。編集は \`docs/superpowers/manifest.yaml\` を直し \`node scripts/gen-design-index.mjs\` で再生成。\n\n${counts}(active ${active.length} / archived ${archived.length})\n`; out += groupSection('Active', active); out += groupSection('Archived', archived); return out; }