This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
// Generates docs/superpowers/index.md from docs/superpowers/manifest.yaml.
|
||||
// Usage: node scripts/gen-design-index.mjs
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { renderIndex } from './lib/design-index.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SP = resolve(__dirname, '../docs/superpowers');
|
||||
|
||||
// `yaml` lives in ui/node_modules — resolve relative to ui/package.json,
|
||||
// exactly like scripts/validate-design-docs.mjs does. Guard the resolution so a
|
||||
// missing `yaml` doesn't throw at import time (which would break any module that
|
||||
// statically imports buildIndex, e.g. validate-design-docs.mjs --check).
|
||||
let parseYaml = null;
|
||||
try {
|
||||
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
|
||||
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
|
||||
} catch {
|
||||
// resolved lazily-checked in buildIndex()
|
||||
}
|
||||
|
||||
export function buildIndex() {
|
||||
if (!parseYaml) throw new Error("design-index gen: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
|
||||
const manifest = parseYaml(readFileSync(join(SP, 'manifest.yaml'), 'utf-8')) ?? { docs: [] };
|
||||
return renderIndex(manifest.docs ?? []);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
writeFileSync(join(SP, 'index.md'), buildIndex(), 'utf-8');
|
||||
console.log('gen: wrote docs/superpowers/index.md');
|
||||
}
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install MAESTRO as a systemd service so it starts automatically on boot.
|
||||
#
|
||||
# Fills in deploy/maestro.service (a template) from the current checkout — node
|
||||
# path, project directory and run user are auto-detected — then installs and
|
||||
# enables the unit. systemd supervises `node dist/main.js` directly; it does not
|
||||
# build, so build first (`scripts/server.sh start` or `npm run build`).
|
||||
#
|
||||
# scripts/install-systemd.sh # user service (no root, runs as you) [default]
|
||||
# scripts/install-systemd.sh --print # preview generated unit, install nothing
|
||||
# scripts/install-systemd.sh --mode system # system-wide service (needs sudo)
|
||||
# scripts/install-systemd.sh --run-as bot # run as a specific user (system mode only)
|
||||
# scripts/install-systemd.sh --name maestro # override service name
|
||||
# scripts/install-systemd.sh --no-start # enable for boot but don't start now
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE="$PROJECT_DIR/deploy/maestro.service"
|
||||
|
||||
MODE="user" # user | system (default: user — no root, runs as you)
|
||||
NAME="maestro"
|
||||
RUN_AS="" # system mode only; default resolved below
|
||||
PRINT_ONLY=0
|
||||
NO_START=0
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
# Consume the value of a `--opt value` flag, erroring cleanly if it's missing
|
||||
# (a bare `shift 2` on the last arg would abort under `set -e` with no message).
|
||||
need_val() { [[ $# -ge 2 ]] || die "$1 requires a value"; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode) need_val "$@"; MODE="$2"; shift 2 ;;
|
||||
--mode=*) MODE="${1#*=}"; shift ;;
|
||||
--name) need_val "$@"; NAME="$2"; shift 2 ;;
|
||||
--name=*) NAME="${1#*=}"; shift ;;
|
||||
--run-as) need_val "$@"; RUN_AS="$2"; shift 2 ;;
|
||||
--run-as=*) RUN_AS="${1#*=}"; shift ;;
|
||||
--print|--dry-run) PRINT_ONLY=1; shift ;;
|
||||
--no-start) NO_START=1; shift ;;
|
||||
-h|--help)
|
||||
# Print the leading comment block (after the shebang) as usage text.
|
||||
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
|
||||
exit 0 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$MODE" == "system" || "$MODE" == "user" ]] || die "--mode must be 'system' or 'user' (got '$MODE')"
|
||||
[[ "$NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || die "invalid --name '$NAME'"
|
||||
[[ -f "$TEMPLATE" ]] || die "template not found: $TEMPLATE"
|
||||
|
||||
# --- Auto-detect substitution values ------------------------------------------
|
||||
NODE_BIN="$(command -v node || true)"
|
||||
[[ -n "$NODE_BIN" ]] || die "node not found in PATH"
|
||||
|
||||
if [[ "$MODE" == "system" ]]; then
|
||||
# Run user: explicit flag > invoking user under sudo > current user.
|
||||
# Never default to root (the app should not build/run as root).
|
||||
RUN_USER="${RUN_AS:-${SUDO_USER:-$USER}}"
|
||||
[[ "$RUN_USER" != "root" ]] || echo "warning: run user resolves to 'root'; pass --run-as <user> to run the app as a non-root user." >&2
|
||||
# Existence is enforced at install time (below), not for --print previews.
|
||||
else
|
||||
# user mode: the service belongs to the invoking user's systemd manager.
|
||||
# Running under sudo would resolve $USER to root and install a stray unit
|
||||
# under /root — almost never intended. Steer to the right path instead.
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
die "user mode as root would install under /root. Run WITHOUT sudo, or for a machine-wide service use: --mode system --run-as <user>"
|
||||
fi
|
||||
[[ -z "$RUN_AS" ]] || echo "warning: --run-as is ignored in user mode (the service always runs as '$USER'); use --mode system to run as another user." >&2
|
||||
RUN_USER="$USER"
|
||||
fi
|
||||
|
||||
# --- Preflight (skipped for --print) ------------------------------------------
|
||||
if [[ "$PRINT_ONLY" -eq 0 ]]; then
|
||||
[[ -f "$PROJECT_DIR/dist/main.js" ]] || die "dist/main.js missing — build first: scripts/server.sh start (or npm run build)"
|
||||
if [[ "$MODE" == "system" ]]; then
|
||||
id "$RUN_USER" >/dev/null 2>&1 || die "run user '$RUN_USER' does not exist (pass --run-as <user>)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Generate the unit from the template --------------------------------------
|
||||
generate_unit() {
|
||||
# Substitute placeholders. PROJECT_DIR/NODE_BIN are absolute paths without
|
||||
# '|' or '&', safe as sed replacements.
|
||||
sed \
|
||||
-e "s|@PROJECT_DIR@|$PROJECT_DIR|g" \
|
||||
-e "s|@NODE@|$NODE_BIN|g" \
|
||||
-e "s|@RUN_USER@|$RUN_USER|g" \
|
||||
"$TEMPLATE" | grep -v '^#' | grep -v '^$' \
|
||||
| if [[ "$MODE" == "user" ]]; then
|
||||
# A user manager owns the process — drop User=, use user-session targets,
|
||||
# and don't wait on the system-only network-online.target.
|
||||
sed \
|
||||
-e '/^User=/d' \
|
||||
-e '/^Wants=network-online.target/d' \
|
||||
-e 's|^After=network-online.target|After=default.target|' \
|
||||
-e 's|^WantedBy=multi-user.target|WantedBy=default.target|'
|
||||
else
|
||||
cat
|
||||
fi
|
||||
}
|
||||
|
||||
UNIT_TEXT="$(generate_unit)"
|
||||
|
||||
if [[ "$PRINT_ONLY" -eq 1 ]]; then
|
||||
echo "# --- generated ${NAME}.service (mode=${MODE}) ---"
|
||||
echo "$UNIT_TEXT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Install ------------------------------------------------------------------
|
||||
if [[ "$MODE" == "system" ]]; then
|
||||
UNIT_PATH="/etc/systemd/system/${NAME}.service"
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
priv() { "$@"; }
|
||||
else
|
||||
command -v sudo >/dev/null 2>&1 || die "system install needs root; re-run with sudo"
|
||||
priv() { sudo "$@"; }
|
||||
fi
|
||||
echo "Installing $UNIT_PATH (run user: $RUN_USER)…"
|
||||
printf '%s\n' "$UNIT_TEXT" | priv tee "$UNIT_PATH" >/dev/null
|
||||
priv systemctl daemon-reload
|
||||
if [[ "$NO_START" -eq 1 ]]; then
|
||||
priv systemctl enable "$NAME"
|
||||
echo "Enabled for boot. Start later with: sudo systemctl start $NAME"
|
||||
else
|
||||
priv systemctl enable --now "$NAME"
|
||||
fi
|
||||
echo "Done. Status: systemctl status $NAME"
|
||||
echo " Logs: journalctl -u $NAME -f"
|
||||
else
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
UNIT_PATH="$UNIT_DIR/${NAME}.service"
|
||||
mkdir -p "$UNIT_DIR"
|
||||
echo "Installing $UNIT_PATH …"
|
||||
printf '%s\n' "$UNIT_TEXT" > "$UNIT_PATH"
|
||||
systemctl --user daemon-reload
|
||||
# Linger lets the user service run at boot without an active login session.
|
||||
if ! loginctl enable-linger "$USER" 2>/dev/null; then
|
||||
echo "warning: could not enable linger for '$USER'; the service may not start until you log in." >&2
|
||||
echo " Retry with: sudo loginctl enable-linger $USER" >&2
|
||||
fi
|
||||
if [[ "$NO_START" -eq 1 ]]; then
|
||||
systemctl --user enable "$NAME"
|
||||
echo "Enabled for boot. Start later with: systemctl --user start $NAME"
|
||||
else
|
||||
systemctl --user enable --now "$NAME"
|
||||
fi
|
||||
echo "Done. Status: systemctl --user status $NAME"
|
||||
echo " Logs: journalctl --user -u $NAME -f"
|
||||
fi
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Appends unregistered docs/superpowers/**/*.md entries to manifest.yaml.
|
||||
// Uses yaml.parseDocument for comment-preserving round-trip.
|
||||
// Idempotent: re-running adds 0 entries when all docs are already registered.
|
||||
// Usage: node scripts/scaffold-manifest.mjs
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { scaffoldEntryObject, findUnregistered } from './lib/design-index.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SP = resolve(__dirname, '../docs/superpowers');
|
||||
|
||||
// `yaml` lives in ui/node_modules — same resolution pattern as validate-design-docs.mjs
|
||||
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
|
||||
const YAML = await import(pathToFileURL(require.resolve('yaml')).href);
|
||||
|
||||
const SUBDIRS = ['specs', 'plans', 'archived/specs', 'archived/plans'];
|
||||
|
||||
function listDocs() {
|
||||
const out = [];
|
||||
for (const d of SUBDIRS) {
|
||||
const abs = join(SP, d);
|
||||
if (!existsSync(abs)) continue;
|
||||
for (const f of readdirSync(abs)) {
|
||||
if (f.endsWith('.md')) out.push(`${d}/${f}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const mfPath = join(SP, 'manifest.yaml');
|
||||
const rawYaml = existsSync(mfPath) ? readFileSync(mfPath, 'utf-8') : 'docs:\n';
|
||||
const doc = YAML.parseDocument(rawYaml);
|
||||
|
||||
// Ensure `docs` key exists as a sequence (handle null scalar from 'docs:\n' seed)
|
||||
const existingSeq = doc.get('docs', true);
|
||||
if (!(existingSeq instanceof YAML.YAMLSeq)) {
|
||||
doc.set('docs', new YAML.YAMLSeq());
|
||||
}
|
||||
const seq = doc.get('docs', true); // true = return the node itself, not the JS value
|
||||
seq.flow = false; // force block style for human curability
|
||||
|
||||
// Build the set of already-registered paths
|
||||
const registered = new Set(
|
||||
(seq.items ?? []).map((n) => n.get('path'))
|
||||
);
|
||||
|
||||
const unregistered = findUnregistered(listDocs(), registered);
|
||||
|
||||
for (const rel of unregistered) {
|
||||
const md = readFileSync(join(SP, rel), 'utf-8');
|
||||
const entry = scaffoldEntryObject(rel, md);
|
||||
const node = doc.createNode(entry);
|
||||
// Attach # TODO: verify topic comment to the topic scalar node
|
||||
const topicItem = node.get('topic', true);
|
||||
if (topicItem) topicItem.comment = ' TODO: verify topic';
|
||||
seq.add(node);
|
||||
}
|
||||
|
||||
writeFileSync(mfPath, String(doc), 'utf-8');
|
||||
console.log(`scaffold: added ${unregistered.length} entries (total ${seq.items.length})`);
|
||||
@@ -0,0 +1,58 @@
|
||||
// Validates docs/superpowers/manifest.yaml against files on disk.
|
||||
// Usage: node scripts/validate-design-docs.mjs [--check]
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { validateManifest } from './lib/design-index.mjs';
|
||||
import { buildIndex } from './gen-design-index.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SP = resolve(__dirname, '../docs/superpowers');
|
||||
|
||||
// `yaml` lives in ui/node_modules — resolve relative to ui/package.json,
|
||||
// exactly like scripts/validate-help-docs.mjs does.
|
||||
let parseYaml;
|
||||
try {
|
||||
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
|
||||
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
|
||||
} catch {
|
||||
console.error("design-docs validation: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SUBDIRS = ['specs', 'plans', 'archived/specs', 'archived/plans'];
|
||||
|
||||
export function listDocs() {
|
||||
const out = [];
|
||||
for (const d of SUBDIRS) {
|
||||
const abs = join(SP, d);
|
||||
if (!existsSync(abs)) continue;
|
||||
for (const f of readdirSync(abs)) if (f.endsWith('.md')) out.push(`${d}/${f}`);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// Main guard: only run validation side-effects when executed directly.
|
||||
// Importing this module (e.g. to use listDocs) will NOT trigger the block below.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
// FIX I-1: guard against missing manifest.yaml (Task 3 hasn't generated it yet)
|
||||
const manifestRaw = existsSync(join(SP, 'manifest.yaml'))
|
||||
? readFileSync(join(SP, 'manifest.yaml'), 'utf-8')
|
||||
: 'docs: []';
|
||||
const manifest = parseYaml(manifestRaw) ?? { docs: [] };
|
||||
const { errors, warnings } = validateManifest(manifest.docs ?? [], listDocs());
|
||||
|
||||
for (const w of warnings) console.warn(`[warn] ${w}`);
|
||||
for (const e of errors) console.error(`[error] ${e}`);
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const cur = existsSync(join(SP, 'index.md')) ? readFileSync(join(SP, 'index.md'), 'utf-8') : '';
|
||||
if (cur !== buildIndex()) {
|
||||
console.error('[error] index.md is stale — run node scripts/gen-design-index.mjs');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (errors.length) { console.error(`design-docs validation FAILED (${errors.length} errors)`); process.exit(1); }
|
||||
console.log(`design-docs validation OK (${(manifest.docs ?? []).length} entries, ${warnings.length} warnings)`);
|
||||
}
|
||||
Reference in New Issue
Block a user