/** * 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。 * * 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開—— * 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞, * 只是下一個讀的人會以為自己讀到的是全部。 */ 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('