起錶與停錶寫在同一份正本裡成對出現,讀的人一眼看得出這段計時涵蓋到哪;兩份各加一節 「計時範圍」把兩端指出來。 sdlc-plan 記兩段:議題建立之前那段在第一步記下開始時間、議題建立之後以補登記上去; 之後起錶,回報那一步停錶。補登排在議題建立之後、起錶之前,順序反過來的話補登會被 time-log 當成「這一步做過了」而跳過。 sdlc-analyze 在它分析的那顆需求議題上起錶,起點放在第一段開頭——分析最耗時的正是 共識之前那一段,從第二段才起的話那段時間永遠是零。因此「共識摘要之前不對 Gitea 產生任何寫入」改寫成「不寫入任何**內容**」,並把碼錶明文除外、寫上理由:它記的是 工時,不是內容。這一條與 repo 擁有者確認過。 sdlc-feat 的「碼錶一律由使用者自己停」收斂成「**別顆議題上的**錶一律由使用者自己停」 ——它原本讀起來像全域規則,而現在每道指令都會停自己起的那一支。 正本之間改以步驟**名稱**互指,不用編號:編號會整批位移,名字不會。測試跟著改用新的 promptStep 輔助函式,以名字框出某一步的內容,別人插一步時不會無聲地框到另一段上。 議題 #57 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.3 KiB
JavaScript
114 lines
4.3 KiB
JavaScript
/**
|
||
* 流程正本與規則正本的共用檢查。
|
||
*
|
||
* 這些檔案是文件不是程式,但它們是各指令實際交付的東西:正本的平台中立性決定
|
||
* 轉接檔能不能一份寫到底,段落結構決定下游解析得到什麼。靠人記不牢,用測試釘住。
|
||
*/
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取出正本裡某一個編號步驟的內容,**以名字取而不是以編號取**。
|
||
* 步驟會增刪、編號會整批位移,名字不會;用編號寫的測試會在別人插一步時無聲地
|
||
* 框到另一段內容上,而那種失敗看起來像是正本掉了東西。
|
||
* @param {string} prompt 正本內容
|
||
* @param {string} name 步驟名,例如 '產生圖解版總覽'
|
||
* @returns {string} 該步驟的標題與內文,到下一個 `### ` 為止
|
||
*/
|
||
export function promptStep(prompt, name) {
|
||
const start = prompt.search(new RegExp(`^### \\d+\\. ${name}$`, 'm'));
|
||
assert.ok(start >= 0, `正本裡找不到「${name}」這一步`);
|
||
const rest = prompt.slice(start);
|
||
const end = rest.slice(1).search(/^### /m);
|
||
return end === -1 ? rest : rest.slice(0, end + 1);
|
||
}
|
||
|
||
/**
|
||
* 斷言模板的 `## 標題` 就是這組段落,順序一致。
|
||
* 段落順序即下游抽取契約的解析依據,兩邊必須一起改。
|
||
*/
|
||
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}\\}\\}$`));
|
||
}
|