From 9582553c41e008b4c3d3906443f25daf6afbb096 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:39:43 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(=E8=AD=B0=E9=A1=8C=E8=A7=A3=E6=9E=90):?= =?UTF-8?q?=20=E5=8B=BE=E9=81=B8=20checkbox=20=E7=9A=84=E7=B2=BE=E7=A2=BA?= =?UTF-8?q?=E6=9B=BF=E6=8F=9B=EF=BC=8C=E4=B8=A6=E7=B5=B1=E4=B8=80=E6=B8=85?= =?UTF-8?q?=E5=96=AE=E9=A0=85=E7=9A=84=E6=96=87=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tickLine 把指定那一行的方框換成已勾,其餘一字不動。四件事決定它會不會靜靜改壞議題: - **跳過圍欄。** 這是 issue-body.js 全檔的前提,而勾選是本檔唯一會寫回議題的路徑。 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態, 把它當成待辦勾下去,改壞的是一張圖。 - **限定段落。** 待辦與整體驗收常有一模一樣的一句話,不限定就會回報「分不出來」, 而使用者其實講得很清楚。理由與 upsertLineInSection 相同:弄錯的代價是靜靜改壞內容。 - **整行比對,認不出就交回 ambiguous。** 巢狀待辦底下常有一樣的驗收,賭第一個會讓 進度條指著錯的那一項,而沒有人會去比對編輯紀錄。 - **[ ]、[x]、[X] 指的是同一行。** [X] 是合法的 GFM,Gitea 也渲染成已勾;只認小寫的話, 中斷後重跑會硬失敗,錯誤訊息還會誣指「議題被改過」。 清單項的文法收斂成一份 LIST_ITEM,parseChecklistItem 與 tickLine 共用。先前兩端各寫一份, 鬆緊不一致:`- [ ]甲` 抽得出來卻勾不動,正本那句「一律用 wp-extract 給的 raw」就成了 做不到的指示。沒有方框的項目交回 no-checkbox,不再謊報「已經勾過」。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 91 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/scripts/issue-body.js b/scripts/issue-body.js index aaf89de..0777eb7 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -148,18 +148,17 @@ export function checklistInSection(body, section) { * @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null} */ function parseChecklistItem(line) { - // [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。 - // text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。 - const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/); + // 文法與 tickLine 共用 LIST_ITEM:抽得出來的行,勾選端就要收得下。 + // text 靠 trim 修掉 CRLF 的 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。 + const item = LIST_ITEM.exec(line); if (!item) return null; - const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/); - const text = (box ? box[2] : item[2]).trim(); + const text = item[3].trim(); if (text === '') return null; return { indent: item[1].length, - value: { text, done: box ? box[1].toLowerCase() === 'x' : false, raw: line }, + value: { text, done: item[2]?.toLowerCase() === '[x]', raw: line }, }; } @@ -259,6 +258,86 @@ function isSeparator(cells) { return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell)); } +/** + * 一行清單項的文法:符號或編號清單,後面可以有一個 checkbox。 + * + * 全檔只有這一份定義。抽取端(parseChecklistItem)與勾選端(tickLine)若各寫一份, + * 遲早會鬆緊不一——抽得出來卻勾不動的那一行,會讓「一律用 wp-extract 給的 raw」 + * 變成做不到的指示。 + */ +const LIST_ITEM = /^(\s*)(?:[-*+]|\d+\.)\s+(\[[ xX]\])?\s*([\s\S]*)$/; + +/** + * 這一行是不是清單項(有沒有 checkbox 都算)。 + * + * `--tick` 用它驗輸入,而且刻意不要求 checkbox:抽取端會把「忘了寫 checkbox 的待辦」 + * 也收成一項待辦,那種 raw 要走到 tickLine 才能得到「去議題上補成 checkbox」這句話, + * 在入口就擋掉只會回一個看不出該怎麼辦的格式錯誤。 + * @param {string} line + * @returns {boolean} + */ +export function isListItem(line) { + return LIST_ITEM.test(line); +} + +/** + * 勾起一行 checkbox:把 `raw` 那一行的方框換成已勾,其餘一字不動。 + * + * 三件事都限定在目標段落之內、且跳過圍欄,理由與 upsertLineInSection 相同—— + * 弄錯的代價是靜靜改壞別人的內容。勾選是這個檔案裡唯一會寫回議題的路徑, + * 而工作包模板的架構圖就是一塊 fenced mermaid:裡面出現減號開頭的行是常態, + * 把它當成待辦勾下去,改壞的是一張圖。 + * + * 用整行精確比對而不是「找那段文字」,因為巢狀待辦底下常有一模一樣的驗收 + * (兩項待辦各有一條「加上測試」)。認不出是哪一行時交回 ambiguous 讓呼叫端報錯, + * 不賭第一個——猜錯的話議題上的進度條會指著錯的那一項,而沒有人會去比對編輯紀錄。 + * + * 勾選狀態與大小寫都不影響比對:`[ ]`、`[x]`、`[X]` 指的是同一行, + * 已經勾過就交回 already,讓中斷後重跑是安靜的 no-op 而不是失敗。 + * + * 本函式不拋錯——它是純解析,錯誤碼由呼叫端決定。 + * + * @param {string} body 議題 body + * @param {string} raw 抽取契約交出的原始 markdown 行,逐字包含縮排與行尾的 \r + * @param {string} [section] 限定在這個段落內找;省略時找全文(圍欄照樣不算) + * @returns {{status: 'ticked'|'already'|'not-found'|'ambiguous'|'no-checkbox'|'no-section', body?: string, line?: string, count: number}} + */ +export function tickLine(body, raw, section) { + const item = LIST_ITEM.exec(raw); + if (!item || item[2] === undefined) return { status: 'no-checkbox', count: 0 }; + + const rows = [...eachLine(body)]; + const { start, end } = section === undefined + ? { start: -1, end: rows.length } + : sectionBounds(rows, section); + if (section !== undefined && start === -1) return { status: 'no-section', count: 0 }; + + /** 同一行的三種寫法都指向它自己:比對時一律正規化成未勾的小寫版本 */ + const normalize = (line) => line.replace(/\[[ xX]\]/, '[ ]'); + const wanted = normalize(raw); + const ticked = raw.replace(/\[[ xX]\]/, '[x]'); + + const hits = []; + for (let i = start + 1; i < end; i += 1) { + if (rows[i].inFence) continue; + if (normalize(rows[i].line) === wanted) hits.push(i); + } + + if (hits.length === 0) return { status: 'not-found', count: 0 }; + if (hits.length > 1) return { status: 'ambiguous', count: hits.length }; + + const [at] = hits; + // 已勾與否看方框本身,不比整行字串:`[X]` 是合法的 GFM,Gitea 也渲染成已勾, + // 用字串相等判斷會把它當成還沒勾,於是重跑時硬把大寫改成小寫 + if (LIST_ITEM.exec(rows[at].line)[2].toLowerCase() === '[x]') { + return { status: 'already', line: rows[at].line, count: 1 }; + } + + const lines = rows.map((row) => row.line); + lines[at] = ticked; + return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 }; +} + /** * 在指定段落裡就地更新(或補上)一行「前綴+值」。 * From 586b746b5556ff1638f1c1e4632baf0caf60440f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:39:44 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(issue-update):=20=E4=BB=A5=20--tick=20?= =?UTF-8?q?=E5=8B=BE=E5=BE=85=E8=BE=A6=EF=BC=8C=E4=B8=A6=E5=9C=A8=E6=B2=92?= =?UTF-8?q?=E6=9D=B1=E8=A5=BF=E5=8F=AF=E6=94=B9=E6=99=82=E4=B8=8D=E9=80=81?= =?UTF-8?q?=E7=A9=BA=E7=9A=84=20PATCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --tick 收抽取契約交出的那一整行 raw,--section 指出它在哪一個段落。四種擋下來的情況 各有錯誤碼,因為使用者的下一步不同:找不到(抽取結果過期,重抽)、同段落出現多次 (請改寫議題上重複的說法)、那一項沒有方框(去議題上補)、段落不存在(對照輸出確認)。 一律報錯不盲改——改壞了議題的進度條會說謊,而沒有人會去比對 body 的編輯紀錄。 --tick 的輸入只驗「是不是清單項」,不要求方框。抽取端會把忘了寫 checkbox 的項目也收成 一項待辦,那種 raw 要走到 tickLine 才拿得到「去議題上補成 checkbox」這句話; 在入口就擋掉,使用者只會得到一個看不出該怎麼辦的格式錯誤。 沒有任何欄位要改時整個 PATCH 都不送:空的 PATCH 會把議題的 updated_at 推新,在列表上 浮起來像是有人動過。這個判斷做在試跑分支之前——放在後面的話,試跑會預告一個實跑根本 不會發的請求,而那是最難查的那種落差。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-update.js | 119 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/scripts/issue-update.js b/scripts/issue-update.js index 4ca1ba1..05aca4b 100644 --- a/scripts/issue-update.js +++ b/scripts/issue-update.js @@ -12,10 +12,15 @@ * 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新, * 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。 * + * 以及勾待辦:`--tick` 收抽取契約交出的那一整行 `raw`,只把它的方框換成已勾。 + * 認不出是哪一行、或那一行已經不在議題上時一律報錯,不盲改——改壞了議題的進度條會說謊, + * 而沒有人會去比對 body 的編輯紀錄。 + * * 用法: * node scripts/issue-update.js --repo owner/name --index 12 * [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N] - * [--overview-url <網址>] [--host <網址>] [--dry-run] + * [--overview-url <網址>] [--tick '' [--section 待辦]] + * [--host <網址>] [--dry-run] */ import { ScriptError, @@ -28,7 +33,7 @@ import { preflight, resolveLogin, } from './lib.js'; -import { upsertLineInSection } from './issue-body.js'; +import { isListItem, tickLine, upsertLineInSection } from './issue-body.js'; /** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */ const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)'; @@ -36,7 +41,7 @@ const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)'; main(async () => { const flags = parseFlags(process.argv.slice(2), { required: ['repo', 'index'], - optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'], + optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'tick', 'section', 'host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); @@ -44,11 +49,18 @@ main(async () => { const dueDate = parseDueDate(flags['due-date']); const days = parseDays(flags['estimate-days']); const overviewUrl = parseOverviewUrl(flags['overview-url']); + const tick = parseTick(flags.tick, flags.section); - if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) { + if ( + flags.milestone === undefined && + dueDate === null && + days === null && + overviewUrl === null && + tick === null + ) { throw new ScriptError( 'NOTHING_TO_UPDATE', - '至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個', + '至少要指定 --milestone、--due-date、--estimate-days、--overview-url 或 --tick 其中一個', ); } @@ -64,20 +76,38 @@ main(async () => { if (dueDate !== null) { payload.due_date = `${dueDate}T00:00:00Z`; } - if (days !== null || overviewUrl !== null) { - const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`); - let body = issue.body ?? ''; + let current = null; + let 勾起的那一行 = null; + let 已經勾過 = false; + + if (days !== null || overviewUrl !== null || tick !== null) { + current = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`); + let body = current.body ?? ''; if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`); if (overviewUrl !== null) { body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`); } - // 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄 - if (body !== issue.body) payload.body = body; + if (tick !== null) { + const result = applyTick(body, tick, flags.section); + body = result.body; + 勾起的那一行 = result.line; + 已經勾過 = result.已經勾過; + } + if (body !== current.body) payload.body = body; } + // 全部都已經是現在這個樣子就不送:空的 PATCH 會把議題的 updated_at 推新, + // 在列表上浮起來像是有人動過。這個判斷要做在試跑分支之前, + // 否則試跑會預告一個實跑根本不會發的請求。 + const noop = Object.keys(payload).length === 0; + const requests = noop ? [] : [{ method: 'PATCH', path, body: payload }]; + if (flags['dry-run']) { - return { dryRun: true, repo, index, requests: [{ method: 'PATCH', path, body: payload }] }; + return { dryRun: true, repo, index, 勾起的那一行, 已經勾過, requests }; + } + if (noop) { + return { repo, index, updated: [], 勾起的那一行, 已經勾過, url: current.html_url }; } const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`); @@ -85,11 +115,78 @@ main(async () => { repo, index, updated: Object.keys(payload), + 勾起的那一行, + 已經勾過, url: issue.html_url, }; }); +/** + * 把 tickLine 的結果轉成這一層的錯誤碼。 + * 認不出是哪一行就報錯而不是猜——精確替換的價值全在這裡。 + */ +function applyTick(body, raw, section) { + const result = tickLine(body, raw, section); + + if (result.status === 'no-section') { + throw new ScriptError( + 'SECTION_NOT_FOUND', + `議題上沒有「${section}」這個段落;請確認 --section 的名稱與議題上的 \`## 標題\` 完全一致`, + ); + } + if (result.status === 'no-checkbox') { + throw new ScriptError( + 'NOT_A_CHECKBOX', + `議題上這一項沒有 checkbox,沒有方框可以勾:${raw.trim()};` + + '請先在議題上把它補成 `- [ ] …` 的寫法', + ); + } + if (result.status === 'not-found') { + throw new ScriptError( + 'RAW_NOT_FOUND', + `議題上找不到這一行:${raw.trim()};` + + '手上的抽取結果可能已經過期(議題被改過),請重新執行 wp-extract 再試', + ); + } + if (result.status === 'ambiguous') { + throw new ScriptError( + 'RAW_AMBIGUOUS', + `這一行在議題上出現了 ${result.count} 次,分不出要勾哪一個:${raw.trim()};` + + '請把議題上重複的那幾項改寫成看得出差別的說法,再重新抽取', + ); + } + return { + body: result.status === 'ticked' ? result.body : body, + line: result.line, + 已經勾過: result.status === 'already', + }; +} + +/** + * `--tick` 收的是一整行 raw,不是一段文字——精確替換的前提是它逐字等於議題上的那一行。 + * 判斷用 issue-body 導出的同一份文法:抽取端收得下的,這裡就要收得下。 + */ +function parseTick(value, section) { + if (value === undefined) { + if (section !== undefined) { + throw new ScriptError('MISSING_FLAG', '--section 是給 --tick 用的,單獨指定沒有作用'); + } + return null; + } + if (value.includes('\n')) { + throw new ScriptError('BAD_RAW', '--tick 一次只勾一行,收到的內容夾帶了換行'); + } + if (!isListItem(value)) { + throw new ScriptError( + 'BAD_RAW', + `--tick 需要一整行清單項(例如「- [ ] 解析九個段落」),收到的是 ${value}`, + ); + } + return value; +} + + function parseDueDate(value) { if (value === undefined) return null; if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { From 6e0fa92e73a9a28f0c5c9469ec6de7204964b3df Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:39:48 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(=E8=A6=8F=E5=89=87=E6=AD=A3=E6=9C=AC):?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E5=AF=A6=E4=BD=9C=E8=A6=8F=E7=AF=84?= =?UTF-8?q?=E8=88=87=E8=A8=BB=E8=A7=A3=E6=A0=BC=E5=BC=8F=E5=B0=8D=E7=85=A7?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩份規則只存在於本 plugin 裡,由流程正本指名讀取,不寫進目標專案的任何檔案。 coding-standards.md 管規則:六種專案檔對應語言、認不出就停下來問;分層看職責不看目錄, 三層各寫功能/邏輯/資料源註解,服務層要標註呼叫的方法讓 reviewer 追得到呼叫鏈; 屬性的用途註解遞迴到每一層,並附真實資料範例,優先取自 MCP,推理來的要明講未經驗證 ——不註明的話,會有人照著沒對過的格式寫解析。 comment-styles.md 只管格式:六種語言各一節,都附可照抄的方法註解與屬性註解範例。 Go 的「以識別字開頭」與 Python 的「docstring 在定義的下一行」各自點名,那是最常被 照抄成別的語言寫法的兩處。 Co-Authored-By: Claude Opus 5 (1M context) --- references/coding-standards.md | 57 +++++++++++++++++ references/comment-styles.md | 110 +++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 references/coding-standards.md create mode 100644 references/comment-styles.md diff --git a/references/coding-standards.md b/references/coding-standards.md new file mode 100644 index 0000000..270a535 --- /dev/null +++ b/references/coding-standards.md @@ -0,0 +1,57 @@ +# 實作規範 + +改目標專案的程式碼時照這份做。這份規則只存在於本 plugin 裡,**不寫入目標專案的任何檔案** +——目標專案的 `CLAUDE.md`、`AGENTS.md` 與設定檔一律不碰。 + +## 先認語言,再動手 + +改任何一個檔案之前,先從專案檔認出這是什麼語言: + +| 專案檔 | 語言 | +| --- | --- | +| `*.csproj`、`*.sln` | C# | +| `composer.json` | PHP | +| `package.json` | JavaScript/TypeScript | +| `go.mod` | Go | +| `pom.xml`、`build.gradle` | Java | +| `pyproject.toml`、`setup.py` | Python | + +認出來之後,對照 `references/comment-styles.md` 取得該語言的註解格式。 + +**認不出來就停下來問,不要猜。** 猜錯的代價是滿檔案格式不對的註解,比沒有註解更難清理。 +同一個 repo 裡有多種語言時,以**正在改的那個檔案**所屬的語言為準。 + +## 分層看職責,不看目錄 + +目錄名稱會騙人:叫 `services/` 的資料夾裡常有一半是控制層。判斷依據一律是**這段程式在做什麼**。 + +| 層 | 怎麼認 | 要寫什麼註解 | +| --- | --- | --- | +| 控制層 | 對外的介面:HTTP handler、CLI 進入點、事件訂閱者、對外 API | **功能註解**——這個介面在做什麼、誰會呼叫它 | +| 服務層 | 所有邏輯:判斷、計算、流程編排 | **邏輯註解**——這段邏輯在解決什麼問題,並**標註它呼叫的所有方法** | +| 存取層 | 任何碰資料來源的東西:DB、外部 API、檔案、快取、訊息佇列 | **資料源註解**——資料從哪裡來、是哪一張表/哪一支 API | + +服務層要標註呼叫的方法,是為了讓 reviewer **追得到呼叫鏈**:看一個方法就知道它會往下走到哪裡, +不必逐層點開。 + +## 屬性一律要有用途註解 + +每一個屬性都寫它的用途。**屬性本身是類別時遞迴處理**——巢狀結構的每一層都要有, +不能只註解最外層然後說「詳見該類別」。 + +用途註解要附**真實的資料範例**,讓人知道實際格式長什麼樣(是 `2026-09-17` 還是 +`2026/09/17`,是 `TWD` 還是 `NTD`)。 + +範例的來源有優先順序: + +1. **優先從 MCP 取得**——能連到真實資料來源時,取真的值。 +2. 取不到就以邏輯推理,並**明確註明「由邏輯推理、未經驗證」**。 + +註明這件事不能省。未經驗證的範例本身有用,但讓人誤以為它經過驗證就會出事—— +有人會照著那個格式寫解析。 + +## 邊界 + +- 不改與這次待辦無關的程式碼。看到順手想修的東西,記下來、說出來,不要摸進這次的變更裡。 +- 不動目標專案的設定檔、CI 設定與相依版本,除非待辦本身就是在做那件事。 +- 既有程式碼的註解不符合這份規範時,**只補你改到的那些**,不要順手重寫整個檔案。 diff --git a/references/comment-styles.md b/references/comment-styles.md new file mode 100644 index 0000000..c9dd9c0 --- /dev/null +++ b/references/comment-styles.md @@ -0,0 +1,110 @@ +# 註解格式對照表 + +各語言的註解怎麼寫。先用 `references/coding-standards.md` 的專案檔對照認出語言,再查這裡。 + +規範本身(哪一層寫什麼、屬性要附真實資料範例)在 `coding-standards.md`,這份只管**格式**。 + +## C# + +XML 文件註解,`///` 起頭。屬性用 ``,範例寫在 `` 或 summary 末尾。 + +```csharp +/// 依訂單編號取回訂單主檔。呼叫 OrderRepository.FindById。 +/// 訂單編號,例如 "ORD-20260917-0012" +public Order GetOrder(string orderId) + +/// 成立時間,ISO 8601 帶時區。例:2026-09-17T14:03:00+08:00 +public DateTimeOffset CreatedAt { get; set; } +``` + +## PHP + +PHPDoc,`/** */`。屬性用 `@var`,範例接在說明後面。 + +```php +/** + * 依訂單編號取回訂單主檔。呼叫 OrderRepository::findById()。 + * + * @param string $orderId 訂單編號,例如 "ORD-20260917-0012" + */ +public function getOrder(string $orderId): Order + +/** @var string 幣別代碼,ISO 4217。例:TWD */ +private string $currency; +``` + +## JavaScript/TypeScript + +JSDoc,`/** */`。TypeScript 本身已經有型別,所以註解只寫**用途與範例**,不要複述型別。 + +```js +/** + * 依訂單編號取回訂單主檔。呼叫 orderRepository.findById。 + * @param {string} orderId 訂單編號,例如 "ORD-20260917-0012" + */ +async function getOrder(orderId) + +/** 幣別代碼,ISO 4217。例:TWD */ +currency; +``` + +## Go + +`//` 起頭,**以被註解的識別字開頭**(Go 的慣例,`go doc` 會照這個排版)。 + +```go +// GetOrder 依訂單編號取回訂單主檔。呼叫 orderRepo.FindByID。 +func GetOrder(orderID string) (*Order, error) + +type Order struct { + // Currency 是幣別代碼,ISO 4217。例:TWD + Currency string +} +``` + +## Java + +Javadoc,`/** */`。 + +```java +/** + * 依訂單編號取回訂單主檔。呼叫 OrderRepository#findById。 + * + * @param orderId 訂單編號,例如 "ORD-20260917-0012" + */ +public Order getOrder(String orderId) + +/** 幣別代碼,ISO 4217。例:TWD */ +private String currency; +``` + +## Python + +docstring,`"""..."""`,寫在定義的**下一行**(不是上一行)。屬性用行內 `#` 或 dataclass 的 docstring。 + +```python +def get_order(order_id: str) -> Order: + """依訂單編號取回訂單主檔。呼叫 OrderRepository.find_by_id。 + + Args: + order_id: 訂單編號,例如 "ORD-20260917-0012" + """ + +@dataclass +class Order: + currency: str # 幣別代碼,ISO 4217。例:TWD +``` + +## 未經驗證的範例怎麼標 + +範例取不到真實來源時,照該語言的格式把註明寫進註解裡,**不要另起一行 TODO**: + +```js +/** 幣別代碼,ISO 4217。例:TWD(由邏輯推理、未經驗證) */ +``` + +```python +currency: str # 幣別代碼,ISO 4217。例:TWD(由邏輯推理、未經驗證) +``` + +這句話要留在程式碼裡,讓後面的人知道這個格式還沒有人對過。 From a45e981c95a7eccad9ef784f9d7940b4f02852bb Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:39:48 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat(=E6=B5=81=E7=A8=8B=E6=AD=A3=E6=9C=AC):?= =?UTF-8?q?=20sdlc-feat=20=E5=8A=A0=E5=85=A5=E7=AC=AC=E4=BA=8C=E6=AE=B5?= =?UTF-8?q?=E3=80=8C=E9=80=90=E9=A0=85=E5=AF=A6=E4=BD=9C=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一項一項做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。 過程不打斷:二十項待辦不按二十次同意,只印進度;也不為了勾選留留言——勾選改的是 body, 進度條自己會動,逐項留言會把議題洗版,reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 真正該停下來問的只有三種,列出來了。 規則正本指名讀取,不在這裡複述——抄過來就會有兩份各自演化的規則。 中斷後重跑從 Gitea 的勾選狀態接續,不看任何本機檔案;重複勾選是安靜的 no-op, 所以不確定某一項有沒有勾到時直接再勾一次即可,不必先查。 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-feat.md | 80 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index 66938c3..d01d4fe 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -8,6 +8,8 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、 第一段**領取與開工準備**:把工作包安全地認領下來,開始計時,備妥開工的分支。 這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。 +第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。 + 這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 ## 輸入 @@ -107,9 +109,87 @@ node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> - 來源分支、新分支名、分支是新建還是接上既有 - 未處理留言數與未關閉的先決議題(若有) +## 第二段:逐項實作 + +### 7. 認出語言,讀規則正本 + +改任何一個檔案之前,先依專案檔認出這是什麼語言,再讀兩份規則正本: + +- `references/coding-standards.md` — 分層判定與各層要寫什麼註解 +- `references/comment-styles.md` — 該語言的註解格式 + +規則以那兩份為準,這裡不複述——抄過來就會有兩份各自演化的規則。只強調兩件最常被跳過的: +**認不出語言就停下來問、不要猜**,以及**規則只存在於本 plugin 裡**, +不寫進目標專案的任何檔案。 + +屬性的資料範例**優先從 MCP 取得**;取不到就以邏輯推理,並照 `comment-styles.md` 的寫法 +在註解裡註明「由邏輯推理、未經驗證」。這句註明不能省,否則後面的人會照著沒對過的格式寫解析。 + +### 8. 一項一項做 + +依 `wp-extract` 給的 `待辦` 順序做。每一項的做法: + +1. 讀它底下的 `驗收`——那是「這一項做到什麼程度算完成」的定義。 +2. 實作,照 `coding-standards.md` 的分層與註解規範。 +3. 這一項的驗收都成立了,才算完成。 + +**過程不打斷。** 不要每做完一項就問一次「可以繼續嗎」——二十項待辦不該按二十次同意。 +只印進度,例如 `[3/12] 已完成:解析九個段落`。 + +真正需要停下來問的只有三種:語言認不出來、待辦的意思有歧義、做下去會超出工作包的 +`範圍邊界`。除此之外一路做完。 + +### 9. 做完一項就勾一項 + +``` +node scripts/issue-update.js --repo --index <編號> \ + --tick '' --section 待辦 +``` + +`--tick` 收的是抽取契約交出的**那一整行 `raw`**,逐字包含縮排;它只把那一行的方框換成 +已勾,議題其餘部分一字不動。待辦與它底下的驗收各自是一行,各勾各的。 + +`--section` 是那一項所在的段落:勾 `待辦` 裡的項目就給 `待辦`,勾 `整體驗收` 就給 +`整體驗收`。**一定要給**——兩個段落常有一模一樣的一句話,不給就分不出要勾哪一個。 + +**不要自己拼那一行**,一律用 `wp-extract` 給的 `raw`。四種擋下來的情況都照實說,不要繞過去: + +| 錯誤碼 | 意思 | 下一步 | +| --- | --- | --- | +| `RAW_NOT_FOUND` | 議題上找不到這一行 | 手上的抽取結果過期了(議題被改過);重跑 `wp-extract` 再試 | +| `RAW_AMBIGUOUS` | 這一行在同一個段落裡出現不只一次 | 分不出要勾哪個;請使用者把重複的那幾項改寫成看得出差別的說法 | +| `NOT_A_CHECKBOX` | 議題上那一項沒有方框 | 請使用者把它補成 `- [ ] …`;**不要自己改寫議題** | +| `SECTION_NOT_FOUND` | `--section` 的段落不存在 | 對照 `wp-extract` 的輸出確認段落名稱 | + +**不要為了勾選在議題上留留言。** 勾選改的是 body,進度條自己會動;逐項留言會把議題洗版, +reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 + +### 10. 中斷後重跑 + +進度完全由 Gitea 上的勾選狀態推導,**不看任何本機檔案**。重跑這一段時: + +1. 重新 `wp-extract`,看 `待辦` 裡哪些 `done` 已經是 `true`。 +2. 從第一個還沒勾的接下去做。 +3. 已經勾過的項目再 `--tick` 一次是安靜的 no-op(回傳 `已經勾過: true`,不發 PATCH), + 所以不確定某一項有沒有勾到時,直接再勾一次即可,不必先查。 + +### 11. 回報 + +全部待辦完成後印一份小結,不寫回議題: + +- 幾項待辦、幾項驗收,全部勾選完成 +- 改了哪些檔案,各屬於哪一層 +- 有沒有待辦因為 `範圍邊界` 而被刻意不做 +- 語言與註解格式用的是哪一份對照 +- **哪些資料範例是推理來的**(MCP 取不到的那些),讓 reviewer 知道哪幾個格式還沒人對過 + ## 邊界 - 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。 +- 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。 +- 不把實作規範或註解格式寫進目標專案的任何檔案。 +- 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。 +- 不為了勾選在議題上留留言。 - 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。 - 不代替使用者停錶,也不在被鎖擋下時繞過去。 - 不替使用者決定來源分支。 From 8ea6a2a8b3a18b64e17deba5134f927a4ef1f755 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:39:54 +0000 Subject: [PATCH 5/5] =?UTF-8?q?test(=E9=80=90=E9=A0=85=E5=AF=A6=E4=BD=9C):?= =?UTF-8?q?=20=E8=A6=86=E8=93=8B=E5=8B=BE=E9=81=B8=E7=9A=84=E4=BA=94?= =?UTF-8?q?=E7=A8=AE=E5=8D=B1=E9=9A=AA=E3=80=81=E5=85=A9=E4=BB=BD=E8=A6=8F?= =?UTF-8?q?=E5=89=87=E6=AD=A3=E6=9C=AC=E8=88=87=E6=AD=A3=E6=9C=AC=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 勾選的測試全部繞著「會不會改錯行」打轉,五種都是 code review 抓出來的實際缺陷: 圍欄裡長得像 checkbox 的那一行不會被改到、不同段落的同一句話靠 --section 分得開、 大寫 [X] 重跑是 no-op、方框後沒有空白照樣勾得到、沒有方框的項目給的是指路的錯誤 而不是謊報已勾過。 另外釘住抽取端與勾選端的一致性:wp-extract 交得出來的每一種 raw,--tick 都要收得下。 patchOf 收進 helpers——它先前在三個測試檔裡各有一份一模一樣的定義。 Co-Authored-By: Claude Opus 5 (1M context) --- test/coding-standards-assets.test.js | 136 ++++++++++ test/helpers/stub-gitea.js | 10 + test/issue-tick.test.js | 376 +++++++++++++++++++++++++++ test/issue-update.test.js | 24 +- test/project-add.test.js | 3 +- test/sdlc-feat-assets.test.js | 89 ++++++- 6 files changed, 630 insertions(+), 8 deletions(-) create mode 100644 test/coding-standards-assets.test.js create mode 100644 test/issue-tick.test.js diff --git a/test/coding-standards-assets.test.js b/test/coding-standards-assets.test.js new file mode 100644 index 0000000..79c8f03 --- /dev/null +++ b/test/coding-standards-assets.test.js @@ -0,0 +1,136 @@ +/** + * 實作規範與註解格式對照表這兩份規則正本。 + * + * 它們是 /sdlc-feat 第二段實際交付的東西:規範寫漏一條,產出的程式碼就少一種註解, + * 而那要等 reviewer 看到才會發現。對照表少一種語言,agent 就會開始猜格式。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readReference } from './helpers/prompt-doc.js'; + +const standards = readReference('coding-standards'); +const styles = readReference('comment-styles'); + +/** + * 切出一個 `## 標題` 段落。 + * 以整行比對而不是 indexOf:`## Java` 是 `## JavaScript` 的前綴, + * 用 indexOf 會切到錯的那一節,而且切出來還是有內容的,錯得很安靜。 + */ +function sectionOf(doc, heading) { + const lines = doc.split('\n'); + const start = lines.findIndex((line) => line.trim() === `## ${heading}`); + if (start === -1) return null; + + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => line.startsWith('## ')); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +} + +// ── 實作規範 ─────────────────────────────────────────────────────── + +test('六種專案檔都對得到語言', () => { + for (const file of [ + '\\*\\.csproj', + 'composer\\.json', + 'package\\.json', + 'go\\.mod', + 'pom\\.xml', + 'pyproject\\.toml', + ]) { + assert.match(standards, new RegExp(file), `專案檔對照缺少 ${file}`); + } +}); + +test('認不出語言時要停下來問,而且說明了為什麼不猜', () => { + assert.match(standards, /認不出來就停下來問/); + assert.match(standards, /不要猜/); + assert.match(standards, /比沒有註解更難清理/, '要說出猜錯的代價,否則這條規則會被當成客套話'); +}); + +test('分層判定明講看職責不看目錄', () => { + assert.match(standards, /看職責,不看目錄/); + assert.match(standards, /目錄名稱會騙人/); +}); + +test('三層各自要寫哪一種註解都寫明了', () => { + for (const [layer, comment] of [ + ['控制層', '功能註解'], + ['服務層', '邏輯註解'], + ['存取層', '資料源註解'], + ]) { + const row = standards.split('\n').find((line) => line.includes(layer) && line.includes('|')); + assert.ok(row, `${layer}沒有出現在分層表裡`); + assert.match(row, new RegExp(comment), `${layer}要寫的是${comment}`); + } +}); + +test('服務層要標註呼叫的方法,並說明理由是追呼叫鏈', () => { + assert.match(standards, /標註它呼叫的所有方法/); + assert.match(standards, /追得到呼叫鏈/); +}); + +test('屬性註解要遞迴,而且明講不能只註解最外層', () => { + assert.match(standards, /屬性本身是類別時遞迴處理/); + assert.match(standards, /不能只註解最外層/); +}); + +test('資料範例的來源有優先序,且未經驗證時要註明', () => { + assert.match(standards, /優先從 MCP 取得/); + assert.match(standards, /由邏輯推理、未經驗證/); + assert.match(standards, /有人會照著那個格式寫解析/, '要說出不註明的代價'); +}); + +test('明講不寫入目標專案的任何檔案', () => { + assert.match(standards, /不寫入目標專案的任何檔案/); + assert.match(standards, /CLAUDE\.md/); +}); + +// ── 註解格式對照表 ───────────────────────────────────────────────── + +test('六種語言各有一節,且都附可照抄的程式碼範例', () => { + for (const [language, marker] of [ + ['C#', '///'], + ['PHP', '@var'], + ['JavaScript/TypeScript', 'JSDoc'], + ['Go', 'go doc'], + ['Java', 'Javadoc'], + ['Python', 'docstring'], + ]) { + const body = sectionOf(styles, language); + assert.ok(body, `對照表缺少 ${language}`); + assert.match(body, new RegExp(marker.replace(/[/#]/g, '\\$&')), `${language} 缺少 ${marker}`); + assert.match(body, /```/, `${language} 要有可照抄的範例,不要只用文字描述`); + } +}); + +test('每個語言的範例都同時示範了方法註解與屬性註解', () => { + const sections = styles.split(/^## /m).filter((s) => s.includes('```')); + for (const section of sections) { + const name = section.split('\n')[0].trim(); + if (name === '未經驗證的範例怎麼標') continue; + assert.match(section, /例:|例如/, `${name} 的範例要示範「附真實資料範例」這件事`); + } +}); + +test('Go 的慣例(以識別字開頭)有被指出來,不是照抄別的語言', () => { + assert.match(sectionOf(styles, 'Go'), /以被註解的識別字開頭/); +}); + +test('Python 的 docstring 位置有講清楚在定義的下一行', () => { + const python = sectionOf(styles, 'Python'); + assert.match(python, /下一行/); + assert.match(python, /不是上一行/, '這是最容易寫錯的一點,要明講'); +}); + +test('未經驗證的註明怎麼寫,兩種語言各有一個可照抄的寫法', () => { + const section = sectionOf(styles, '未經驗證的範例怎麼標'); + assert.match(section, /不要另起一行 TODO/); + assert.ok((section.match(/由邏輯推理、未經驗證/g) ?? []).length >= 2, '至少要有兩種語言的寫法'); +}); + +// ── 兩份的分工 ───────────────────────────────────────────────────── + +test('規範與格式分開:對照表不重複寫一遍規範', () => { + assert.match(styles, /這份只管\*\*格式\*\*/); + assert.match(standards, /comment-styles\.md/, '規範要指名去哪裡查格式'); +}); diff --git a/test/helpers/stub-gitea.js b/test/helpers/stub-gitea.js index df1dd1e..5974500 100644 --- a/test/helpers/stub-gitea.js +++ b/test/helpers/stub-gitea.js @@ -103,3 +103,13 @@ export async function withStubGitea(t, routes) { /** 把腳本指向這台假 Gitea 的環境變數 */ export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }); + +/** + * 找出腳本真正發出的那一個 PATCH。 + * 前置檢查對 `issues/0` 的探針也是 PATCH,但它打在一顆不存在的議題上、不改動任何東西, + * 不該被當成腳本的寫入(見 lib.js 的 checkIssueWrite)。 + * @returns {object|undefined} 沒發出寫入時為 undefined + */ +export function patchOf(stub) { + return stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/issues/0')); +} diff --git a/test/issue-tick.test.js b/test/issue-tick.test.js new file mode 100644 index 0000000..e43e6aa --- /dev/null +++ b/test/issue-tick.test.js @@ -0,0 +1,376 @@ +/** + * 勾選待辦:以抽取契約給的 `raw` 做精確字串替換。 + * + * 這一支的全部價值在「只動目標那一行」。改壞的代價很安靜——議題上的進度條會說謊, + * 而沒有人會去比對 body 的編輯紀錄。所以三種危險各有測試: + * - 同一句話在 body 裡出現兩次(巢狀待辦底下常有一模一樣的驗收,例如「加上測試」) + * - `raw` 對不上(議題被人改過,手上的抽取結果已經過期) + * - 已經勾過了(中斷後重跑) + * 前兩種寧可報錯也不猜,第三種要安靜地當作沒事。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 12; + +/** + * 一份有巢狀待辦的工作包 body。刻意埋了三個地雷: + * - 兩項驗收的文字一模一樣(同一段落內,真的分不出來) + * - 架構圖是 fenced mermaid,裡面有一行長得像 checkbox + * - 整體驗收裡有一行與待辦完全相同(不同段落,靠 --section 分得出來) + */ +const BODY = `## 架構圖 + +\`\`\`mermaid +flowchart TD + A[讀議題] --> B[勾待辦] +- [ ] 解析九個段落 +\`\`\` + +## 待辦 + +- [ ] 解析九個段落 + - [ ] 缺段落回空值 + - [ ] 加上測試 +- [ ] 待辦解析成巢狀結構 + - [ ] 加上測試 + +## 整體驗收 + +- [ ] 輸出欄位與契約完全一致 +- [ ] 待辦解析成巢狀結構 +`; + +function routes(overrides = {}, { body = BODY } = {}) { + return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { + status: 200, + body: { number: INDEX, title: '逐項實作並即時勾選待辦', body, html_url: 'https://example.com/12' }, + }, + [`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({ + status: 200, + body: { number: INDEX, ...req.body, html_url: 'https://example.com/12' }, + }), + ...overrides, + }); +} + +const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); + +/** + * 把「待辦」段落裡的某一行換成指定寫法。 + * 不直接對整份 BODY 做 replace:圍欄裡那一行排在待辦之前,會被換掉的是它。 + */ +function withTodoLine(from, to) { + const at = BODY.indexOf('## 待辦'); + return BODY.slice(0, at) + BODY.slice(at).replace(from, to); +} + +const run = (args, stub) => + runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: envFor(stub), + }); + + +// ── 精確替換 ─────────────────────────────────────────────────────── + +test('勾起指定的那一行,其餘一字不動', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub); + + assert.equal(code, 0, JSON.stringify(json)); + const body = patchOf(stub).body.body; + assert.match(body, /- \[x\] 解析九個段落/); + assert.equal( + body.replace('- [x] 解析九個段落', '- [ ] 解析九個段落'), + BODY, + '把那一個方框換回去之後,應該逐字等於原本的 body', + ); +}); + +test('縮排的驗收項目也勾得到,縮排原樣保留', async (t) => { + const stub = await withStub(t); + + const { code } = await run(['--tick', ' - [ ] 缺段落回空值', '--section', '待辦'], stub); + + assert.equal(code, 0); + assert.match(patchOf(stub).body.body, /\n {2}- \[x\] 缺段落回空值\n/); +}); + +test('回報勾起來的是哪一行,讓呼叫端印進度', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub); + + assert.equal(json.data.勾起的那一行, '- [x] 解析九個段落'); + assert.equal(json.data.已經勾過, false); +}); + +// ── 文字重複時不誤傷 ─────────────────────────────────────────────── + +test('同一句話在 body 裡出現兩次時報錯,不賭第一個', async (t) => { + // 巢狀待辦底下常有一模一樣的驗收;猜錯的話,議題上的進度條會指著錯的那一項 + const stub = await withStub(t); + + const { code, json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦'], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'RAW_AMBIGUOUS'); + assert.match(json.error.message, /2/, '要說出它出現了幾次'); + assert.equal(patchOf(stub), undefined, '分不出是哪一行就不要寫'); +}); + +// ── raw 對不上 ───────────────────────────────────────────────────── + +test('raw 不匹配時回錯誤,不盲改', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run(['--tick', '- [ ] 這一行議題上沒有', '--section', '待辦'], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'RAW_NOT_FOUND'); + assert.match(json.error.message, /重新抽取|過期/, '要指出手上的抽取結果可能過期了'); + assert.equal(patchOf(stub), undefined); +}); + +test('差一個空白也算對不上:精確替換就是要精確', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub); + + assert.equal(json.error.code, 'RAW_NOT_FOUND'); +}); + +// ── 冪等:中斷後重跑 ─────────────────────────────────────────────── + +test('已經勾過的項目不再動它,也不發 PATCH', async (t) => { + const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落'); + const stub = await withStub(t, {}, { body }); + + const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub); + + assert.equal(code, 0, '重跑不該失敗,那會讓中斷後的接續變成人工作業'); + assert.equal(json.data.已經勾過, true); + assert.equal(patchOf(stub), undefined, '沒有變化就不要在議題上留下一筆空的編輯'); +}); + +test('直接給已勾的那一行也算數,同樣是 no-op', async (t) => { + const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落'); + const stub = await withStub(t, {}, { body }); + + const { code, json } = await run(['--tick', '- [x] 解析九個段落', '--section', '待辦'], stub); + + assert.equal(code, 0); + assert.equal(json.data.已經勾過, true); +}); + +test('大寫的 [X] 重跑時也是安靜的 no-op,不是 RAW_NOT_FOUND', async (t) => { + // [X] 是合法的 GFM,Gitea 會把它渲染成已勾,wp-extract 也回報 done:true。 + // 比對時若只認小寫,中斷後重跑會硬失敗,而錯誤訊息還會誣指「議題被改過」。 + const body = withTodoLine('- [ ] 待辦解析成巢狀結構', '- [X] 待辦解析成巢狀結構'); + const stub = await withStub(t, {}, { body }); + + const { code, json } = await run(['--tick', '- [X] 待辦解析成巢狀結構', '--section', '待辦'], stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.已經勾過, true); + assert.equal(patchOf(stub), undefined); +}); + +// ── 輸入驗證 ─────────────────────────────────────────────────────── + +test('--tick 的內容根本不是清單項時擋下', async (t) => { + // 「是清單項但忘了寫方框」是另一種情況,錯誤碼不同——那種要指路去議題上補 + const stub = await withStub(t); + + const { json } = await run(['--tick', '解析九個段落'], stub); + + assert.equal(json.error.code, 'BAD_RAW'); + assert.match(json.error.message, /清單項/); +}); + +test('--section 沒有配 --tick 時說清楚它沒有作用', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--section', '待辦', '--milestone', '第一階段'], stub); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--tick/); +}); + +test('--tick 夾帶換行時擋下:一次只勾一行', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--tick', '- [ ] 甲\n- [ ] 乙'], stub); + + assert.equal(json.error.code, 'BAD_RAW'); +}); + +// ── 圍欄與段落:不誤傷、也不假歧義 ───────────────────────────────── + +test('圍欄裡長得像 checkbox 的那一行不算,不會被改到', async (t) => { + // 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態。 + // issue-body.js 全檔的前提是「圍欄裡的東西不是內容」,勾選是唯一會寫回去的路徑, + // 漏掉這件事就會靜靜改壞圖。 + const stub = await withStub(t); + + const { code } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub); + + assert.equal(code, 0); + const body = patchOf(stub).body.body; + const fence = body.slice(body.indexOf('```mermaid'), body.indexOf('## 待辦')); + assert.match(fence, /- \[ \] 解析九個段落/, '圍欄裡那一行要原封不動'); +}); + +test('不同段落有同一行時,--section 分得出來', async (t) => { + // 待辦與整體驗收各有一行「待辦解析成巢狀結構」,限定段落就不該是歧義 + const stub = await withStub(t); + + const { code, json } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '待辦'], stub); + + assert.equal(code, 0, JSON.stringify(json)); + const body = patchOf(stub).body.body; + const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收')); + const overall = body.slice(body.indexOf('## 整體驗收')); + assert.match(todo, /- \[x\] 待辦解析成巢狀結構/, '待辦那一行要被勾起'); + assert.match(overall, /- \[ \] 待辦解析成巢狀結構/, '整體驗收那一行不該被動到'); +}); + +test('整體驗收段落也勾得到,各勾各的', async (t) => { + const stub = await withStub(t); + + const { code } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '整體驗收'], stub); + + assert.equal(code, 0); + const body = patchOf(stub).body.body; + const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收')); + assert.match(todo, /- \[ \] 待辦解析成巢狀結構/, '待辦那一行不該被動到'); + assert.match(body.slice(body.indexOf('## 整體驗收')), /- \[x\] 待辦解析成巢狀結構/); +}); + +test('--section 指到不存在的段落時報錯,不退回掃全文', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '沒有這一段'], stub); + + assert.equal(json.error.code, 'SECTION_NOT_FOUND'); +}); + +test('沒給 --section 時掃全文,但圍欄照樣不算', async (t) => { + const body = '## 待辦\n\n```\n- [ ] 圍欄裡的假待辦\n```\n\n- [ ] 真正的待辦\n'; + const stub = await withStub(t, {}, { body }); + + const { code } = await run(['--tick', '- [ ] 圍欄裡的假待辦'], stub); + + assert.equal(code, 1, '圍欄裡的行不是內容,找不到才對'); + assert.equal(patchOf(stub), undefined); +}); + +// ── 抽取端與勾選端要對得上 ───────────────────────────────────────── + +test('方框後面沒有空白也勾得到:抽取端收得下的,勾選端就要收得下', async (t) => { + // parseChecklistItem 的文法允許 `- [ ]甲`,wp-extract 會照樣交出它的 raw; + // 勾選端若比抽取端嚴格,正本那句「一律用 wp-extract 給的 raw」就變成做不到的事 + const body = '## 待辦\n\n- [ ]沒有空白的那一項\n'; + const stub = await withStub(t, {}, { body }); + + const { code, json } = await run(['--tick', '- [ ]沒有空白的那一項', '--section', '待辦'], stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.match(patchOf(stub).body.body, /- \[x\]沒有空白的那一項/); +}); + +test('議題上那一項根本沒有 checkbox 時,錯誤要說清楚而不是謊報已勾過', async (t) => { + // wp-extract 會把 `- 忘了寫 checkbox` 當成一項待辦(done:false), + // 但那一行沒有方框可以換。這時要說「去議題上補成 checkbox」,不能回報「已經勾過」 + const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n'; + const stub = await withStub(t, {}, { body }); + + const { code, json } = await run(['--tick', '- 忘了寫 checkbox 的待辦', '--section', '待辦'], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'NOT_A_CHECKBOX'); + assert.match(json.error.message, /補/, '要告訴使用者去議題上把它補成 checkbox'); +}); + +// ── 與既有欄位共存 ───────────────────────────────────────────────── + +test('--tick 可以和別的欄位一起送,共用同一個 PATCH', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/milestones`]: { status: 200, body: [{ id: 3, title: '第一階段' }] }, + }); + + const { code } = await run( + ['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--milestone', '第一階段'], + stub, + ); + + assert.equal(code, 0); + const patch = patchOf(stub); + assert.match(patch.body.body, /- \[x\] 解析九個段落/); + assert.equal(patch.body.milestone, 3); +}); + +test('什麼都沒指定時仍然報 NOTHING_TO_UPDATE', async (t) => { + const stub = await withStub(t); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'NOTHING_TO_UPDATE'); + assert.match(json.error.message, /--tick/, '新欄位也要列進可用清單'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出改完的 body,但不寫進去', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run( + ['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'], + stub, + ); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.match(json.data.requests[0].body.body, /- \[x\] 解析九個段落/); + assert.equal(patchOf(stub), undefined); +}); + +test('--dry-run 在已經勾過時要說「實跑不會發任何請求」', async (t) => { + // 試跑印出一個 PATCH、實跑卻什麼都不送,是最難查的那種落差 + const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落'); + const stub = await withStub(t, {}, { body }); + + const { json } = await run( + ['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'], + stub, + ); + + assert.equal(json.data.已經勾過, true); + assert.deepEqual(json.data.requests, [], '沒有東西要改,預告的請求就該是空的'); +}); + +test('--dry-run 遇到分不清的 raw 一樣報錯,不會等到實跑才發現', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦', '--dry-run'], stub); + + assert.equal(json.error.code, 'RAW_AMBIGUOUS'); +}); + +// ── CRLF 的 body ─────────────────────────────────────────────────── + +test('CRLF 的 body 也勾得到,行尾的 \\r 不被吃掉', async (t) => { + // 議題只要在 Gitea 網頁上被編輯過就是 CRLF;wp-extract 交出的 raw 會連 \r 一起帶著 + const body = BODY.replace(/\n/g, '\r\n'); + const stub = await withStub(t, {}, { body }); + + const { code } = await run(['--tick', '- [ ] 解析九個段落\r', '--section', '待辦'], stub); + + assert.equal(code, 0); + assert.match(patchOf(stub).body.body, /- \[x\] 解析九個段落\r\n/); +}); diff --git a/test/issue-update.test.js b/test/issue-update.test.js index 0dc179d..1fbb74e 100644 --- a/test/issue-update.test.js +++ b/test/issue-update.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; -import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; const INDEX = 12; @@ -39,7 +39,6 @@ const run = (args, stub) => env: envFor(stub), }); -const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0')); // ── Milestone ───────────────────────────────────────────────────── @@ -132,7 +131,24 @@ test('估算沒有變時不重寫 body', async (t) => { await run(['--estimate-days', '3'], stub); - assert.equal('body' in patchOf(stub).body, false, '沒變就不該把 body 塞進 PATCH'); + assert.equal( + patchOf(stub), + undefined, + '沒有任何欄位要改就整個 PATCH 都不發:空的 PATCH 會把議題的 updated_at 推新,' + + '在列表上浮起來像是有人動過', + ); +}); + +test('有別的欄位要改時照樣發 PATCH,但沒變的 body 不跟著被重寫', async (t) => { + const stub = await withStub(t, {}, { + body: '## 關聯\n\n需求議題:#1\n估算人天:3\n', + }); + + await run(['--estimate-days', '3', '--milestone', '第一階段'], stub); + + const patch = patchOf(stub); + assert.equal(patch.body.milestone, 3); + assert.equal('body' in patch.body, false, '估算沒變,body 就不該被塞進去'); }); test('人天必須是正數', async (t) => { @@ -305,7 +321,7 @@ test('連結沒變時不重寫 body', async (t) => { await run(['--overview-url', 'https://example.com/a'], stub); - assert.equal('body' in patchOf(stub).body, false); + assert.equal(patchOf(stub), undefined, '連結沒變、也沒有別的欄位要改,就不發 PATCH'); }); test('不是網址時擋在打 Gitea 之前', async (t) => { diff --git a/test/project-add.test.js b/test/project-add.test.js index 43e6231..02ae71e 100644 --- a/test/project-add.test.js +++ b/test/project-add.test.js @@ -7,7 +7,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; -import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; const INDEX = 12; @@ -39,7 +39,6 @@ const run = (args, stub) => env: envFor(stub), }); -const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0')); // ── 以名稱指定 ───────────────────────────────────────────────────── diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index 03b1f66..5ee051a 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -9,8 +9,9 @@ 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 phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段')); +const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 邊界')); test('正本平台中立,description 前綴正確', () => { assertNeutralPrompt(prompt, 'sdlc-feat'); @@ -95,3 +96,87 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', assert.match(boundary, /不寫任何本機狀態檔/); assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔'); }); + +// ── 第二段:逐項實作 ─────────────────────────────────────────────── + +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('### 8.'); + 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('### 11.')); + 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, /不改與待辦無關的程式碼/); +});