maestro/scripts/lib/design-index.mjs
oss-sync b1292e34b2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

137 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}