Files
tea-sdlc/test/delegation-assets.test.js
T
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

221 lines
10 KiB
JavaScript
Raw 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 test from 'node:test';
import assert from 'node:assert/strict';
import {
DELEGATABLE,
PLATFORM_SPECIFIC,
promptSteps,
readPrompt,
readReference,
} from './helpers/prompt-doc.js';
const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'];
const reference = readReference('delegation');
/** 帶標記的三份正本;另外三份目前沒有可委派的步驟 */
const 有標記的正本 = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat'];
/** `正本/步驟` 這種好讀的鍵,比對失敗時看得出差在哪一步 */
const 鍵 = ({ prompt, name }) => `${prompt}/${name}`;
/** delegation.md 那張表列出來的步驟 */
const 表上的 = () =>
[...reference.matchAll(/^\| `(sdlc-[a-z]+)` \| (.+?) \| (.+?) \|$/gm)].map((m) => ({
prompt: m[1],
name: m[2].trim(),
範圍: m[3].trim(),
}));
/** 正本上實際被標記的步驟 */
const 正本上的 = () =>
PROMPTS.flatMap((prompt) =>
promptSteps(readPrompt(prompt))
.filter((step) => step.marked)
.map((step) => ({ prompt, name: step.name, body: step.body })),
);
// ── 判準正本 ───────────────────────────────────────────────────────
test('delegation.md 的開頭形狀與既有規則正本一致', () => {
assert.equal(reference.startsWith('# '), true, '規則正本一律以 H1 起頭,不放 frontmatter');
assert.match(reference.split('\n')[0], /委派/);
});
/** 判準那一節的四條,各自含標題與理由 */
function 判準逐條() {
const 節 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
return 節.split(/^(?=\d+\. \*\*)/m).filter((one) => /^\d+\. \*\*/.test(one));
}
test('四條判準逐條載明,一條不多一條不少', () => {
const 判準 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
const 條 = 判準逐條().map((one) => /^\d+\. \*\*(.+?)\*\*/.exec(one)[1]);
assert.equal(條.length, 4, `判準應為四條,目前 ${條.length} 條:${條.join('、')}`);
assert.match(判準, /可驗證的成品/);
assert.match(判準, /不會詢問使用者/);
assert.match(判準, /失敗能被呼叫端偵測/);
assert.match(判準, /不直接寫入 Gitea 或 git/);
});
test('第二條與第四條各自標明是硬排除,並各自寫出理由', () => {
// 數「硬排除」出現幾次的話,別處多提一句就會失敗;要問的是「那兩條上面有沒有」
const [, 二, , 四] = 判準逐條();
assert.match(二, /硬排除/, '第二條是硬排除,不是建議');
assert.match(二, /子代理問不到使用者/, '沒寫理由的話,下一個人會把它當成建議而繞過去');
assert.match(四, /硬排除/, '第四條是硬排除,不是建議');
assert.match(四, /失敗沒有人看著/, '理由不是子代理做不好,要寫清楚,否則會被當成不信任');
});
// ── 雙向斷言 ───────────────────────────────────────────────────────
test('正本上被標記的集合,等於 delegation.md 列出的集合', () => {
const 表 = 表上的().map(鍵).sort();
const 正本 = 正本上的().map(鍵).sort();
assert.deepEqual(正本, 表, '改一邊就要改另一邊,否則兩份說法會漂開');
});
// 這一條與上一條刻意重複:雙向斷言只保證兩邊一致,兩邊一起改就一起漂走。
// 把議題點名的那六個逐字釘在這裡,改動才需要有人明確地改掉這份清單。
test('被標記的正好是議題點名的那六個', () => {
assert.deepEqual(正本上的().map(鍵).sort(), [
'sdlc-analyze/算出截止日',
'sdlc-analyze/對四份清單列出疑點',
'sdlc-analyze/產生分析版的圖解總覽',
'sdlc-feat/把議題標題翻成英文',
'sdlc-feat/分批提交',
'sdlc-plan/產生圖解版總覽',
].sort());
});
// ── 判準第二條的迴歸保護 ───────────────────────────────────────────
/** 會問使用者的步驟。子代理問不到人,這些永遠不該被標上可委派。 */
const 會問使用者 = [
['sdlc-plan', '逐項詢問'],
['sdlc-analyze', '逐題問到共識'],
['sdlc-feat', '問來源分支'],
['sdlc-feat', '認出語言,讀規則正本'],
];
test('點名的問到共識類步驟一律未被標記', () => {
for (const [prompt, name] of 會問使用者) {
const step = promptSteps(readPrompt(prompt)).find((one) => one.name === name);
assert.ok(step, `${prompt} 少了「${name}」這一步;步驟改名的話這份清單要跟著改`);
assert.equal(step.marked, false, `${prompt}/${name} 會問使用者,判準第二條硬排除`);
}
});
test('任何看得出在問使用者的步驟都沒有被標記', () => {
// 只認明確的提問語,不認「不要拿去問使用者」那種否定句——那句正好出現在可委派的步驟裡
const 提問語 = /一次問一題|停下來問|等使用者回答|問到共識|問過使用者/;
let 掃過 = 0;
let 認出 = 0;
for (const prompt of PROMPTS) {
for (const step of promptSteps(readPrompt(prompt))) {
掃過 += 1;
if (!提問語.test(step.body)) continue;
認出 += 1;
assert.equal(step.marked, false, `${prompt}/${step.name} 在問使用者,不該標可委派`);
}
}
// 兩道自我檢查:這種掃描最常見的壞法是「一條都沒掃到」,而那時它照樣是綠的。
// 目前有編號步驟的是 plan/analyze/feat/report 四份,sdlc-fix 與 sdlc-sync 沒有編號步驟
assert.ok(掃過 >= 40, `只掃到 ${掃過} 個步驟,正本的步驟標題格式可能變了`);
assert.ok(認出 >= 4, `提問語一個步驟都沒認出來(${認出}),這道保護已經形同虛設`);
});
// ── 判準第四條:不直接寫入 ─────────────────────────────────────────
/** 會寫入 Gitea 或 git 的腳本。被標記的步驟碰到它們,就要寫明哪一半不委派。 */
const 寫入型 = [
'issue-create',
'issue-update',
'issue-link',
'project-add',
'pr-create',
'claim.js',
'branch-prep',
'worktree-ensure',
'worktree-remove',
'timer.js',
'time-log.js',
'commit-split',
];
test('被標記的步驟若碰得到寫入,就要寫明哪一半不委派', () => {
for (const step of 正本上的()) {
const 碰到 = 寫入型.filter((script) => step.body.includes(script));
if (碰到.length === 0) continue;
assert.match(
step.body,
/不委派/,
`${鍵(step)} 用到 ${碰到.join('、')},要寫明那一半留給主流程`,
);
}
});
test('三步部分委派的範圍,判準表上也說得出來', () => {
const 部分 = 表上的().filter((one) => one.範圍 !== '全步');
assert.equal(部分.length, 3, '兩份圖解總覽與分批提交是部分委派');
for (const one of 部分) {
assert.match(one.範圍, /不委派/, `${鍵(one)} 的範圍要說出哪一半不委派`);
}
});
test('另外三份正本一個標記都沒有,與判準正本結尾那句話一致', () => {
for (const prompt of PROMPTS.filter((one) => !有標記的正本.includes(one))) {
assert.equal(
readPrompt(prompt).includes(DELEGATABLE),
false,
`${prompt} 出現了標記,但 delegation.md 結尾說它沒有可委派的步驟`,
);
}
assert.match(reference, /`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟/);
});
// ── 平台中立 ───────────────────────────────────────────────────────
test('判準正本與標記都不指名任何平台的工具', () => {
for (const token of PLATFORM_SPECIFIC) {
assert.equal(reference.includes(token), false, `delegation.md 不該出現平台專屬字樣:${token}`);
}
// 子代理是平台專屬能力,正本只能以能力描述帶過
assert.match(reference, /能力描述/);
assert.match(reference, /不能就自己做/);
});
test('三份帶標記的正本各自說明了這個後綴是什麼意思,並指名判準正本', () => {
for (const prompt of 有標記的正本) {
const text = readPrompt(prompt);
assert.match(text, new RegExp(`## ${DELEGATABLE}的意思`), `${prompt} 要解釋這個後綴`);
assert.match(text, /references\/delegation\.md/, `${prompt} 要指名判準正本,不要把判準抄過去`);
assert.match(text, /不能就自己做/, `${prompt} 要寫成能力描述,讓不支援的平台自然降級`);
}
});
test('標記是標題後綴,不用 emoji 也不用 HTML 註解', () => {
for (const prompt of PROMPTS) {
const text = readPrompt(prompt);
assert.equal(text.includes('<!--'), false, `${prompt}:HTML 註解模型讀不穩,不拿它當標記`);
// 標記只出現在標題後綴與那一節的說明裡,不會單獨浮在內文中間
for (const line of text.split('\n')) {
if (!line.includes(DELEGATABLE)) continue;
// 三種合法位置,寫死成互斥的三條。曾經第二條寫成 /^#{2,3} /,把第一條整個
// 吃掉了——那時候「### 工作包議題 〔可委派〕」這種沒編號的標題也會通過
const 合法 =
new RegExp(`^### \\d+\\. .+ ${DELEGATABLE}$`).test(line) ||
line === `## ${DELEGATABLE}的意思` ||
line.includes(`\`${DELEGATABLE}\``);
assert.ok(合法, `${prompt}:標記出現在不該出現的位置:${line}`);
}
}
});