sync: update from private repo (edc775f2)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
+136
View File
@@ -0,0 +1,136 @@
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;
}
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import { docParts, deriveTopicGuess, extractTitle, hasSupersededBy, STATUSES, scaffoldEntryObject, findUnregistered } from './design-index.mjs';
describe('docParts', () => {
it('spec のパスを分解', () => {
expect(docParts('specs/2026-07-02-foo-design.md')).toEqual({ type: 'spec', date: '2026-07-02', base: '2026-07-02-foo-design', archived: false });
});
it('archived plan', () => {
expect(docParts('archived/plans/2026-03-14-bar.md')).toEqual({ type: 'plan', date: '2026-03-14', base: '2026-03-14-bar', archived: true });
});
});
describe('deriveTopicGuess', () => {
it('spec の -design を除く', () => {
expect(deriveTopicGuess('specs/2026-07-01-workspace-task-search-design.md')).toBe('workspace-task-search');
});
it('plan は接尾なし', () => {
expect(deriveTopicGuess('plans/2026-07-02-workspace-task-search.md')).toBe('workspace-task-search');
});
it('redesign を誤って削らない', () => {
expect(deriveTopicGuess('specs/2026-03-25-memory-redesign.md')).toBe('memory-redesign');
});
});
describe('extractTitle', () => {
it('見出しから接尾を除く', () => {
expect(extractTitle('# ワークスペース内タスク横断検索 実装計画\n\n本文')).toBe('ワークスペース内タスク横断検索');
expect(extractTitle('# A2A プロトコル対応 — 設計書\n')).toBe('A2A プロトコル対応');
});
it('見出し無しは空', () => { expect(extractTitle('本文だけ')).toBe(''); });
});
describe('hasSupersededBy', () => {
it('null/空はなし', () => { expect(hasSupersededBy(null)).toBe(false); expect(hasSupersededBy('')).toBe(false); });
it('文字列はあり', () => { expect(hasSupersededBy('specs/x.md')).toBe(true); });
});
it('STATUSES は6値', () => { expect(STATUSES).toHaveLength(6); });
describe('scaffoldEntryObject', () => {
it('active は unknown, archived は shipped', () => {
const a = scaffoldEntryObject('plans/2026-07-02-foo.md', '# Foo 実装計画\n');
expect(a).toMatchObject({ path: 'plans/2026-07-02-foo.md', type: 'plan', topic: 'foo', status: 'unknown', pr: [], superseded_by: null });
expect(a.title).toBe('Foo');
const b = scaffoldEntryObject('archived/specs/2026-03-14-bar-design.md', '# Bar — 設計書\n');
expect(b.status).toBe('shipped');
expect(b.type).toBe('spec');
});
});
describe('findUnregistered', () => {
it('manifest に無いものだけ返す', () => {
expect(findUnregistered(['a', 'b', 'c'], new Set(['b']))).toEqual(['a', 'c']);
});
});
import { renderIndex, escapeCell, prUrl, BADGE } from './design-index.mjs';
describe('escapeCell', () => {
it('パイプと改行を無害化', () => {
expect(escapeCell('a|b\nc')).toBe('a\\|b c');
});
});
describe('renderIndex', () => {
const entries = [
{ path: 'specs/2026-07-01-x-design.md', title: 'X', type: 'spec', topic: 'x', status: 'design', pr: [], superseded_by: null },
{ path: 'plans/2026-07-02-x.md', title: 'X 計画', type: 'plan', topic: 'x', status: 'design', pr: [712], superseded_by: null },
{ path: 'archived/plans/2026-03-14-y.md', title: 'Y', type: 'plan', topic: 'y', status: 'shipped', pr: [], superseded_by: null },
];
const md = renderIndex(entries);
it('サマリに件数', () => { expect(md).toMatch(/🔵\s*2/); expect(md).toMatch(/🟢\s*1/); });
it('topic 見出しで spec/plan が同居', () => {
const xSection = md.slice(md.indexOf('x'));
expect(xSection).toContain('X');
expect(xSection).toContain('X 計画');
});
it('PR リンクを描く', () => { expect(md).toContain(prUrl(712)); });
it('archived は別セクション', () => { expect(md).toMatch(/archived|アーカイブ/i); });
it('決定的(2回同じ)', () => { expect(renderIndex(entries)).toBe(md); });
it('superseded_by があると後継リンクを描く', () => {
const supersededEntry = {
path: 'specs/2026-01-01-old-design.md',
title: 'Old',
type: 'spec',
topic: 'old',
status: 'superseded',
pr: [],
superseded_by: 'specs/2026-01-01-z-design.md',
};
const successor = {
path: 'specs/2026-01-01-z-design.md',
title: 'Z',
type: 'spec',
topic: 'old',
status: 'shipped',
pr: [],
superseded_by: null,
};
const out = renderIndex([supersededEntry, successor]);
expect(out).toContain('[→](./specs/2026-01-01-z-design.md)');
});
it('同日・同トピック 2エントリを逆順に渡しても同じ出力(C1 order-independence', () => {
const e1 = { path: 'specs/2026-05-01-alpha-design.md', title: 'Alpha', type: 'spec', topic: 'tie', status: 'design', pr: [], superseded_by: null };
const e2 = { path: 'specs/2026-05-01-beta-design.md', title: 'Beta', type: 'spec', topic: 'tie', status: 'design', pr: [], superseded_by: null };
expect(renderIndex([e1, e2])).toBe(renderIndex([e2, e1]));
});
it('未知の status は ❔ バッジにフォールバック', () => {
const weirdEntry = { path: 'specs/2026-06-01-foo-design.md', title: 'Foo', type: 'spec', topic: 'foo', status: 'weird', pr: [], superseded_by: null };
const out = renderIndex([weirdEntry]);
expect(out).toContain('❔');
});
});
import { validateManifest } from './design-index.mjs';
describe('validateManifest', () => {
const ok = { path: 'specs/2026-01-01-a-design.md', title: 'A', type: 'spec', topic: 'a', status: 'shipped', pr: [], superseded_by: null };
it('整合していれば error 無し', () => {
const r = validateManifest([ok], ['specs/2026-01-01-a-design.md']);
expect(r.errors).toEqual([]);
});
it('孤児(ファイルはあるが manifest に無い)を error に', () => {
const r = validateManifest([ok], ['specs/2026-01-01-a-design.md', 'plans/2026-01-02-b.md']);
expect(r.errors.some((e) => e.includes('2026-01-02-b.md'))).toBe(true);
});
it('欠落(manifest にあるがファイルが無い)を error に', () => {
const r = validateManifest([ok], []);
expect(r.errors.some((e) => e.includes('does not exist') || e.includes('欠落'))).toBe(true);
});
it('不正 status を error に', () => {
const bad = { ...ok, status: 'done' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes('status'))).toBe(true);
});
it('superseded_by あり + status!=superseded は warning', () => {
const s = { ...ok, superseded_by: 'specs/2026-01-01-a-design.md' };
const r = validateManifest([s], ['specs/2026-01-01-a-design.md']);
expect(r.warnings.some((w) => w.includes('superseded'))).toBe(true);
});
it('必須フィールド欠如(title が空)を error に', () => {
const bad = { ...ok, title: '' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes("'title'"))).toBe(true);
});
it('不正 type を error に', () => {
const bad = { ...ok, type: 'note' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes('type'))).toBe(true);
});
it('superseded_by が manifest 内に無い path を error に', () => {
const bad = { ...ok, status: 'superseded', superseded_by: 'specs/9999-nonexistent.md' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes('superseded_by') && e.includes('9999-nonexistent'))).toBe(true);
});
it('status=superseded かつ superseded_by 空は warning', () => {
const bad = { ...ok, status: 'superseded', superseded_by: null };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.warnings.some((w) => w.includes('superseded_by'))).toBe(true);
});
it('status=unknown のエントリは warning に件数を含む', () => {
const u1 = { ...ok, path: 'specs/2026-01-01-a-design.md', status: 'unknown' };
const u2 = { ...ok, path: 'plans/2026-01-03-c.md', status: 'unknown' };
const r = validateManifest([u1, u2], ['specs/2026-01-01-a-design.md', 'plans/2026-01-03-c.md']);
expect(r.warnings.some((w) => w.includes('2') && w.includes('unknown'))).toBe(true);
});
});