沒有這條斷言,兩邊會慢慢漂開,而漂開時不會有任何東西報錯:正本上多標一步不會壞,
判準表少列一項也不會壞,只是下一個讀的人會以為自己讀到的是全部。
十三條,重點在三處:正本上被標記的集合等於判準表列出的集合;會問使用者的步驟一律
未被標記(判準第二條的迴歸保護,四個點名的步驟加上一次全域掃描);被標記的步驟碰得到
寫入時要寫明哪一半不委派(第四條)。
掃描類的斷言都補上自我檢查,因為這種測試最常見的壞法是「一條都沒掃到」而它照樣是綠的:
提問語至少要認出四個步驟、至少要掃過四十個步驟。標記位置那一條原本把三種合法位置寫成
`/^#{2,3} /`,那條會把「### 沒編號的標題 〔可委派〕」也放過去,改成三條互斥的規則。
helpers 新增 promptSteps(名字、有沒有標記、內文),promptStep 改建在它上面,
順帶讓步驟標題容得下後綴。
議題 #58
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 lines
10 KiB
JavaScript
221 lines
10 KiB
JavaScript
/**
|
||
* 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。
|
||
*
|
||
* 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開——
|
||
* 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞,
|
||
* 只是下一個讀的人會以為自己讀到的是全部。
|
||
*/
|
||
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}`);
|
||
}
|
||
}
|
||
});
|