/** * 流程正本與規則正本的共用檢查。 * * 這些檔案是文件不是程式,但它們是各指令實際交付的東西:正本的平台中立性決定 * 轉接檔能不能一份寫到底,段落結構決定下游解析得到什麼。靠人記不牢,用測試釘住。 */ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { repoRoot } from './run-script.js'; /** 讀一份流程正本 */ export function readPrompt(name) { return readFileSync(join(repoRoot, 'prompts', `${name}.md`), 'utf8'); } /** 讀一份規則正本 */ export function readReference(name) { return readFileSync(join(repoRoot, 'references', `${name}.md`), 'utf8'); } /** 讀一份輸出模板(markdown) */ export function readTemplate(name) { return readTemplateFile(`${name}.md`); } /** 讀 templates/ 底下任一個檔案,含副檔名 */ export function readTemplateFile(filename) { return readFileSync(join(repoRoot, 'templates', filename), 'utf8'); } /** * 正本裡不該出現的字樣:任何一家助理的工具名、目錄名或呼叫語法。 * 出現任何一個,就代表這份正本已經綁死在某個平台上。 */ export const PLATFORM_SPECIFIC = [ 'AskUserQuestion', 'Claude', 'Codex', 'Antigravity', 'Copilot', 'Kiro', 'OpenCode', 'oh-my-pi', '.claude', '.codex', ]; /** * 斷言一份正本是平台中立的,且 description 帶上指定前綴。 * @param {string} prompt 正本內容 * @param {string} command 指令名,例如 sdlc-plan */ export function assertNeutralPrompt(prompt, command) { const description = prompt.match(/^description:\s*(.+)$/m)?.[1]; assert.ok(description, '正本需要一行 description 供轉接檔取用'); assert.ok( description.startsWith(`僅由 /${command} 指令叫用。`), `description 前綴不符:${description}`, ); assert.equal(prompt.startsWith('---'), false, '正本不該有 YAML frontmatter,那是轉接檔的事'); for (const token of [...PLATFORM_SPECIFIC, `$${command}`]) { assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`); } } /** 可委派的標記。寫成標題後綴,不是 emoji 也不是 HTML 註解——那兩種模型讀不穩。 */ export const DELEGATABLE = '〔可委派〕'; /** * 正本裡的每一個編號步驟:名字、有沒有被標成可委派、以及它的內文。 * * 步驟的界線是下一個 `##` 或 `###` 標題;段落標題(`## 第二段…`)不算步驟, * 但會把前一步收尾,否則一段的最後一步會把整個段落的收場白都吃進來。 * @param {string} prompt 正本內容 * @returns {{name: string, marked: boolean, body: string}[]} */ export function promptSteps(prompt) { const lines = prompt.split('\n'); // 先收齊所有標題的行號,每一步的結尾就是它後面最近的那一個 const 標題行 = []; lines.forEach((line, at) => { if (/^#{2,3} /.test(line)) 標題行.push(at); }); return 標題行 .map((at, i) => ({ at, 到: 標題行[i + 1] ?? lines.length })) .filter(({ at }) => /^### \d+\. /.test(lines[at])) .map(({ at, 到 }) => { const raw = /^### \d+\. (.+)$/.exec(lines[at])[1].trim(); const marked = raw.endsWith(DELEGATABLE); return { name: (marked ? raw.slice(0, -DELEGATABLE.length) : raw).trim(), marked, body: lines.slice(at, 到).join('\n'), }; }); } /** * 取出正本裡某一個編號步驟的內容,**以名字取而不是以編號取**。 * 步驟會增刪、編號會整批位移,名字不會;用編號寫的測試會在別人插一步時無聲地 * 框到另一段內容上,而那種失敗看起來像是正本掉了東西。 * @param {string} prompt 正本內容 * @param {string} name 步驟名,不含可委派後綴 * @returns {string} 該步驟的標題與內文,到下一個 `##` 或 `###` 標題為止 */ export function promptStep(prompt, name) { const step = promptSteps(prompt).find((one) => one.name === name); assert.ok(step, `正本裡找不到「${name}」這一步`); return step.body; } /** * 斷言模板的 `## 標題` 就是這組段落,順序一致。 * 段落順序即下游抽取契約的解析依據,兩邊必須一起改。 */ export function assertTemplateSections(template, sections) { const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim()); assert.deepEqual(headings, sections); } /** * 斷言正本的編號清單逐一交代了這組段落,順序與模板一致。 * @param {string} text 正本內容,或其中相關的一段 */ export function assertPromptListsSections(text, sections) { const listed = [...text.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim()); assert.deepEqual(listed, sections); } /** * 斷言圖表段落只有佔位、沒有寫死的 mermaid 圍欄。 * 正本允許「乾脆不畫」,圍欄寫死在模板裡的話,不畫時會在議題頁留下一塊渲染失敗的空區塊。 * @param {string} heading 圖表段落的標題,例如 '流程圖' * @param {string} nextHeading 其後一個段落的標題,用來框出範圍 */ export function assertDiagramPlaceholderOnly(template, heading, nextHeading) { assert.equal(template.includes('```mermaid'), false); const section = template.slice(template.indexOf(`## ${heading}`), template.indexOf(`## ${nextHeading}`)); assert.match(section.trim(), new RegExp(`^## ${heading}\\s+\\{\\{${heading}\\}\\}$`)); }