From f61ef3fa78ebbaecf73ef0e93302ae459c7fc3aa Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 18 Sep 2026 09:12:08 +0800 Subject: [PATCH] =?UTF-8?q?test(delegation):=20=E6=8A=8A=E6=A8=99=E8=A8=98?= =?UTF-8?q?=E8=88=87=E5=88=A4=E6=BA=96=E6=AD=A3=E6=9C=AC=E9=9B=99=E5=90=91?= =?UTF-8?q?=E7=B6=81=E4=BD=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沒有這條斷言,兩邊會慢慢漂開,而漂開時不會有任何東西報錯:正本上多標一步不會壞, 判準表少列一項也不會壞,只是下一個讀的人會以為自己讀到的是全部。 十三條,重點在三處:正本上被標記的集合等於判準表列出的集合;會問使用者的步驟一律 未被標記(判準第二條的迴歸保護,四個點名的步驟加上一次全域掃描);被標記的步驟碰得到 寫入時要寫明哪一半不委派(第四條)。 掃描類的斷言都補上自我檢查,因為這種測試最常見的壞法是「一條都沒掃到」而它照樣是綠的: 提問語至少要認出四個步驟、至少要掃過四十個步驟。標記位置那一條原本把三種合法位置寫成 `/^#{2,3} /`,那條會把「### 沒編號的標題 〔可委派〕」也放過去,改成三條互斥的規則。 helpers 新增 promptSteps(名字、有沒有標記、內文),promptStep 改建在它上面, 順帶讓步驟標題容得下後綴。 議題 #58 Co-Authored-By: Claude Opus 5 (1M context) --- test/delegation-assets.test.js | 220 +++++++++++++++++++++++++++++++++ test/helpers/prompt-doc.js | 45 +++++-- 2 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 test/delegation-assets.test.js diff --git a/test/delegation-assets.test.js b/test/delegation-assets.test.js new file mode 100644 index 0000000..8262677 --- /dev/null +++ b/test/delegation-assets.test.js @@ -0,0 +1,220 @@ +/** + * 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。 + * + * 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開—— + * 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞, + * 只是下一個讀的人會以為自己讀到的是全部。 + */ +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('