137 lines
5.5 KiB
JavaScript
137 lines
5.5 KiB
JavaScript
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;
|
||
}
|