Files
jiantw83andClaude Opus 5 f61ef3fa78 test(delegation): 把標記與判準正本雙向綁住
沒有這條斷言,兩邊會慢慢漂開,而漂開時不會有任何東西報錯:正本上多標一步不會壞,
判準表少列一項也不會壞,只是下一個讀的人會以為自己讀到的是全部。

十三條,重點在三處:正本上被標記的集合等於判準表列出的集合;會問使用者的步驟一律
未被標記(判準第二條的迴歸保護,四個點名的步驟加上一次全域掃描);被標記的步驟碰得到
寫入時要寫明哪一半不委派(第四條)。

掃描類的斷言都補上自我檢查,因為這種測試最常見的壞法是「一條都沒掃到」而它照樣是綠的:
提問語至少要認出四個步驟、至少要掃過四十個步驟。標記位置那一條原本把三種合法位置寫成
`/^#{2,3} /`,那條會把「### 沒編號的標題 〔可委派〕」也放過去,改成三條互斥的規則。

helpers 新增 promptSteps(名字、有沒有標記、內文),promptStep 改建在它上面,
順帶讓步驟標題容得下後綴。

議題 #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:12:08 +08:00

145 lines
5.5 KiB
JavaScript
Raw Permalink 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.
/**
* 流程正本與規則正本的共用檢查。
*
* 這些檔案是文件不是程式,但它們是各指令實際交付的東西:正本的平台中立性決定
* 轉接檔能不能一份寫到底,段落結構決定下游解析得到什麼。靠人記不牢,用測試釘住。
*/
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}\\}\\}$`));
}