Files
tea-sdlc/test/sdlc-feat-assets.test.js
T
jiantw83andClaude Opus 5 42f5b86299 merge(pr-留言): 把議題支援套到 pr-threads 的共用結構上
#48 把留言讀取抽成 pr-threads 給 pr-watch 與 pr-comments 共用,本分支則在修「純議題讀不了」
與「已處理的判定有兩份」。兩邊動到同一塊,但要的其實是同一件事——#48 的檔頭就寫著
「規則寫兩份遲早會各自演化,一邊認自己打的 +1、另一邊認任何人的」,而那正是本分支在
lib 與 pr-comments 之間發現的那個 bug。

合併的方向是保留 pr-threads 的結構,把修正套進去:

- readGeneral 改名 readGeneralComments 並導出,pr-comments 判斷出是純議題時只叫它。
  先讀 /issues/{index} 再決定要不要翻 review——每個 PR 都是議題,反過來不成立。
- pr-threads 自己那份 markedByMe 拿掉,改用 lib 的 mergedByMe。抽取契約數未整併則數
  用的是同一條規則,現在三處共用一份,#48 擔心的分歧不會再發生。
- pr-comments 的試跑改印 commonRequests:它收得下兩種輸入,而試跑階段還沒讀過議題、
  不知道是哪一種。與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定
  會發的,其餘交給 note。pr-watch 的輸入一定是 PR,繼續用 plannedRequests。

lib.js 與 sdlc-feat.md 兩邊改的是不同區域,三方合併無衝突。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:16:34 +00:00

333 lines
15 KiB
JavaScript

/**
* 正本第一段「領取與開工準備」的規則。
*
* 這些檔案是文件不是程式,但它們是指令實際交付的東西:決策表寫錯,被鎖擋下來的人
* 就會得到錯的下一步;邊界寫漏,第一段就會去做後面幾段的事。靠人記不牢,用測試釘住。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-feat');
/** 各段的內容分開切,避免把別段的字樣誤認成這一段的規則 */
const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界'));
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-feat');
});
test('第一段指名四支腳本,順序為先讀再領、備妥工作樹、最後起錶', () => {
const order = ['wp-extract.js', 'claim.js', 'branch-prep.js', 'timer.js'];
const positions = order.map((name) => phase1.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名');
assert.deepEqual(
[...positions].sort((a, b) => a - b),
positions,
'領取之前要先讀得懂這顆在做什麼;錶則要等工作樹建好之後才起',
);
});
test('錶等工作樹建好之後才起,並說明為什麼', () => {
assert.match(phase1, /錶不在這一步起/, '領取那一步要明講錶還沒起');
assert.match(phase1, /什麼都沒做的時間/, '要說明為什麼後移:失敗的領取不該留下憑空的工時');
});
test('工作樹一律建立,建不起來就中止而不是退回原地切分支', () => {
assert.match(phase1, /一律建立,沒有例外/);
assert.match(phase1, /不要改成在原地切分支/);
assert.match(phase1, /以為自己在隔離環境裡/, '要說明靜默降級的後果');
});
test('實作要在工作樹裡做,路徑從輸出取得', () => {
assert.match(phase1, /worktree/, '工作樹路徑印在哪個欄位要講');
assert.match(phase1, /都在那棵工作樹裡做/);
});
test('工作樹是乾淨的,且機密檔案不會被複製過去', () => {
assert.match(phase1, /沒有安裝依賴/);
assert.match(phase1, /安裝指令/);
assert.match(phase1, /\.env/);
});
test('未處理留言不是 0 時要先停下來提示整併', () => {
assert.match(phase1, /未處理留言數/);
assert.match(phase1, /先停下來/);
assert.match(phase1, /sdlc-sync/);
});
test('領取鎖的四種狀態各自交代了下一步,含放行那一種', () => {
for (const code of ['CLAIMED_BY_OTHER', 'STOPWATCH_ON_THIS_ISSUE', 'STOPWATCH_ON_OTHER_ISSUE']) {
assert.match(phase1, new RegExp(code), `${code} 要出現在決策表裡`);
}
assert.match(phase1, /沒有鎖/, '第四種狀態(放行)也要在表上,否則只剩擋的那幾種');
assert.match(phase1, /不要繞過去/, '被擋下來的處置要明講,不能靠 agent 自由發揮');
});
test('缺標籤是前置條件,不混進領取鎖的四種狀態裡', () => {
const table = phase1.slice(phase1.indexOf('| 狀態'), phase1.indexOf('碼錶一律由使用者自己停'));
assert.equal(table.includes('LABEL_NOT_FOUND'), false, '它不是鎖的狀態,別讓四種變五種');
assert.match(phase1, /LABEL_NOT_FOUND/, '但仍要交代它,否則使用者不知道怎麼辦');
});
test('工作包跨多個 repo 時怎麼開分支,有交代', () => {
assert.match(phase1, /repos/);
assert.match(phase1, /有多顆時逐一確認/);
});
test('兩種擋下來的情境各自寫明處置,且都不替使用者決定', () => {
assert.match(phase1, /SOURCE_NOT_FOUND/);
assert.match(phase1, /不要自己換一個/, '來源分支是使用者的決定,不要自己改指定別支');
assert.match(phase1, /WORKTREE_PATH_TAKEN/);
assert.match(phase1, /不要自己刪/, '路徑上的東西可能還沒保存,不該由 agent 決定刪掉');
});
test('被碼錶擋下時要說明停錶不會動到工作樹', () => {
assert.match(phase1, /停錶不會動到既有的工作樹/);
assert.match(phase1, /以為停錶等於放棄那顆工作包/, '要說明不講清楚的後果');
});
test('碼錶只由使用者自己停,並說明為什麼不代勞', () => {
assert.match(phase1, /由使用者自己停/);
assert.match(phase1, /工時記錯地方/);
});
test('來源分支要問過使用者,且一次一題、附理由與手動輸入', () => {
assert.match(phase1, /一次問一題/);
assert.match(phase1, /手動輸入/);
assert.match(phase1, /不要替他決定|不要替使用者決定/);
});
test('翻譯規則釘住 kebab 與 40 字元上限,並舉出可照抄的例子', () => {
assert.match(phase1, /kebab/);
assert.match(phase1, /40/);
assert.match(phase1, /wp-extract-contract/, '要有一個真的例子,不要只說規則');
assert.match(phase1, /不要把長句截斷/);
});
test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => {
assert.match(phase1, /`--type` 只在來源是開發分支時要給/);
assert.match(phase1, /沿用來源/);
});
test('兩處「不覆蓋他人進度」的保證都有寫出來', () => {
assert.match(phase1, /起點一律是遠端的來源分支/, '根本不碰本機分支,就沒有覆蓋的可能');
assert.match(phase1, /接上去而不是蓋掉/);
});
test('三支寫入型腳本都要求先試跑', () => {
const dryRuns = phase1.match(/--dry-run/g) ?? [];
assert.ok(dryRuns.length >= 3, `三支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
});
test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不改任何一行程式碼/);
assert.match(boundary, /不勾待辦/);
assert.match(boundary, /不開 PR/);
assert.match(boundary, /不自行建立標籤/);
assert.match(boundary, /不在主工作區動手/);
assert.match(boundary, /不退回原地切分支/);
assert.match(boundary, /不寫任何本機狀態檔/);
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
});
test('第三段收尾時告訴使用者之後怎麼查 PR,但不自己反覆跑', () => {
assert.match(phase3, /pr-watch\.js/);
assert.match(phase3, /worktree-remove\.js/, '手動清理的出口也要講,否則沒人知道它在');
assert.match(phase3, /不要自己反覆跑/);
assert.match(phase3, /suggestedAction/, '建議動作是列舉值,要讓使用者知道有這個東西可以判斷');
});
test('邊界擋住「監看報了就自己去跑 sdlc-fix」', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不自動反覆執行 `pr-watch`/);
assert.match(boundary, /只由使用者明確叫用/);
});
// ── 第二段:逐項實作 ───────────────────────────────────────────────
test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => {
assert.match(phase2, /改的是工作樹裡的檔案/);
assert.match(phase2, /落到別顆工作包的分支/, '要說明在主工作區動手的後果');
});
test('第二段指名兩份規則正本,且在改檔之前就要讀', () => {
assert.match(phase2, /references\/coding-standards\.md/);
assert.match(phase2, /references\/comment-styles\.md/);
const readAt = phase2.indexOf('coding-standards.md');
const implementAt = phase2.indexOf('### 9.');
assert.ok(readAt < implementAt, '讀規則要排在動手實作之前');
});
test('認不出語言就停下來問,不自行假設', () => {
assert.match(phase2, /認不出語言就停下來問/);
assert.match(phase2, /不要猜/);
});
test('不把規範寫進目標專案的檔案', () => {
assert.match(phase2, /不寫進目標專案的任何檔案/);
});
test('規則不在正本裡複述,只指名去哪裡讀', () => {
assert.match(phase2, /這裡不複述/);
assert.match(phase2, /兩份各自演化/, '要說出複述的代價,否則下一個人還是會抄過來');
});
test('資料範例優先取自 MCP,取不到要註明未經驗證', () => {
assert.match(phase2, /優先從 MCP 取得/);
assert.match(phase2, /由邏輯推理、未經驗證/);
});
test('回報要點出哪些範例是推理來的', () => {
const report = phase2.slice(phase2.indexOf('### 12.'));
assert.match(report, /哪些資料範例是推理來的/);
});
test('--section 一定要給,並說明不給會怎樣', () => {
assert.match(phase2, /--section/);
assert.match(phase2, /一定要給/);
assert.match(phase2, /分不出要勾哪一個/);
});
test('過程不打斷:不逐項徵求同意,只印進度', () => {
assert.match(phase2, /過程不打斷/);
assert.match(phase2, /不該按二十次同意/);
assert.match(phase2, /只印進度/);
assert.match(phase2, /\[3\/12\]/, '要給一個看得出長相的進度格式,不要只說「印進度」');
});
test('真正該停下來問的情況有列舉,不是一律不問', () => {
assert.match(phase2, /真正需要停下來問的只有三種/);
assert.match(phase2, /範圍邊界/);
});
test('勾選用 issue-update --tick,且明講要用抽取契約給的 raw', () => {
assert.match(phase2, /--tick/);
assert.match(phase2, /不要自己拼那一行/);
assert.match(phase2, /raw/);
});
test('四種勾不動的錯誤各自交代了下一步', () => {
for (const code of ['RAW_NOT_FOUND', 'RAW_AMBIGUOUS', 'NOT_A_CHECKBOX', 'SECTION_NOT_FOUND']) {
assert.match(phase2, new RegExp(code), `${code} 要出現在錯誤表裡`);
}
assert.match(phase2, /重跑 `wp-extract`/);
assert.match(phase2, /不要自己改寫議題/, '議題內容是使用者的,agent 不該代為修改');
});
test('不為了勾選留留言,並說明為什麼', () => {
assert.match(phase2, /不要為了勾選在議題上留留言/);
assert.match(phase2, /洗版/);
});
test('中斷後重跑從 Gitea 的勾選狀態接續,且不看本機檔案', () => {
assert.match(phase2, /不看任何本機檔案/);
assert.match(phase2, /done` 已經是 `true`|done.*true/);
assert.match(phase2, /no-op/, '要說明重複勾選是安全的,否則會有人先查再勾');
});
test('邊界把第二段不做的事也列出來', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /第二段只實作與勾選/);
assert.match(boundary, /不提交、不開 PR、不停錶/);
assert.match(boundary, /不改與待辦無關的程式碼/);
});
// ── 第三段:提交與開立 PR ─────────────────────────────────────────
test('第三段指名兩支腳本,順序為先提交再開 PR', () => {
const order = ['commit-split.js', 'pr-create.js'];
const positions = order.map((name) => phase3.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '兩支腳本都要被指名');
assert.deepEqual([...positions].sort((a, b) => a - b), positions);
});
test('要等待辦全部勾完才進第三段', () => {
assert.match(phase3, /全部待辦都勾完之後才進這一段/);
});
test('--type 是程式碼那一批的類型,其餘由腳本自己認', () => {
assert.match(phase3, /測試、文件與設定檔\s*\n?由腳本自己認出來|由腳本自己認出來/);
assert.match(phase3, /不必也不能指定/);
});
test('--scope 什麼時候要給寫清楚了', () => {
assert.match(phase3, /只在某一批有多個檔案時才需要/);
assert.match(phase3, /單檔那批的 scope 就是檔名/);
});
test('commit 描述要用繁體中文,並交代夾雜英文的處理', () => {
assert.match(phase3, /描述用繁體中文/);
assert.match(phase3, /保留原文/);
});
test('跨兩個功能時要分兩次跑,且指名用哪個旗標做得到', () => {
assert.match(phase3, /分兩次跑/);
assert.match(phase3, /--files/, '光說「分兩次跑」而不說怎麼分,等於沒說');
assert.match(phase3, /失去了分批的意義/);
});
test('PR 描述的八個段落都列出來,且標明順序不能換', () => {
for (const section of [
'摘要', '需求議題', '工作包議題', '變更內容',
'設計重點', '解決的問題', '影響的功能', '測試結果',
]) {
assert.match(phase3, new RegExp(`\\*\\*${section}\\*\\*`), `缺少段落說明:${section}`);
}
assert.match(phase3, /順序不能換/);
});
test('測試結果要放實際輸出,並交代沒有自動化測試時怎麼辦', () => {
assert.match(phase3, /放實際跑過的輸出/);
assert.match(phase3, /已測試通過/, '要指名這句被禁止的寫法');
assert.match(phase3, /手動\s*\n?驗證步驟|手動驗證步驟/);
assert.match(phase3, /補真的內容/);
});
test('標題由腳本設為分支名,不另外指定', () => {
assert.match(phase3, /標題由腳本設為分支名/);
assert.match(phase3, /不必也不能另外指定/);
});
test('先開 PR 再停錶,且 PR 沒開成就不停錶', () => {
assert.match(phase3, /先開 PR 再停錶/);
assert.match(phase3, /沒開成就不停錶/);
assert.match(phase3, /工時要記在真的有做事的那段時間上/);
});
test('PR 的 repo 與議題的 repo 分開講清楚', () => {
assert.match(phase3, /--issue-repo/);
assert.match(phase3, /程式碼所在的 repo/);
assert.match(phase3, /工作包議題所在的/);
assert.match(phase3, /常常不是同一個/, '要說出為什麼需要兩個旗標');
});
test('--base 要明講,不讓腳本猜', () => {
assert.match(phase3, /--base/);
assert.match(phase3, /不替你猜/);
});
test('重跑不會開出第二顆 PR,正本要說', () => {
assert.match(phase3, /重跑不會開出第二顆 PR/);
assert.match(phase3, /created/);
});
test('提交中途失敗的處置有交代,且明講不要自己回捲歷史', () => {
assert.match(phase3, /前面已經建立的那幾顆 commit/);
assert.match(phase3, /不要自己去回捲歷史/);
});
test('兩支腳本都要求先試跑', () => {
const dryRuns = phase3.match(/--dry-run/g) ?? [];
assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
});
test('邊界把第三段不做的事也列出來', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /第三段不改任何一行程式碼/);
assert.match(boundary, /不把「已測試通過」這種空話/);
assert.match(boundary, /不代替使用者決定 commit 的類型與描述/);
});