swe-knowledge — 軟體工程的 Definition of Done
這裡放的是所有增量共用的完成條件。跟每張單獨有的驗收條件(acceptance criteria)分得很 開:AC 寫在那張單凍結的 assertion 裡,DoD 寫在這裡。一張單可以通過全部 AC 卻沒有 done—— assertion 全綠但改動還躺在預設分支上、沒有任何人看得到它,就是那個情況。
所以這幾條不要抄進任何一張單的凍結區。抄進去等於每張單都重簽一次同樣幾行不承載新資訊的 東西,而漏抄的那一張就靜默地少了一條完成條件。
五條
改動住在一條 branch 上,不躺在預設分支。 開的時機是「判定要立案之後、動手之前」—— 一個還沒開工的成功定義直接躺在預設分支上,等於它已經是既成事實。
那條 branch 是自己建的。 改動只推到自己建的 branch。別人建的、或有別人的 PR 在用的 branch,任何情況都不推——推不推得動、用不用 force 都一樣。要在別人還沒併的 branch 之上 做事,從它切一條自己的,PR 的 base 指向它。
所以別人的 branch 推不動的時候,那不是要解決的問題。 它從一開始就不是推的對象, 沒有「換個方法推上去」這一題。2026-09-10 的實例:一顆 PR 的 base 是同事還沒併的 branch,遠端規則讓兩條都推不動,討論裡出現了「那條也一樣推不動」——那條本來就不是我們 會去推的東西。
有一個 PR。 PR 開出來就是實作完成:它是那份改動變成可被別人看見、可被判定的 東西的那一刻。沒有 PR 的改動不管本機多綠都還沒 done。
判定過才進預設分支。 進去的路徑是那個 PR,不是直接推。
push 之前,本機跑完跑得動的驗證。 type check、lint、單元測試、受影響路徑的冒煙。 把 CI 當第一道防線等於把 reviewer 當驗證工具。
reviewer 提的每一條,處置回到那條意見上。 不是「有沒有處理」——是提出者拿不拿 得到那個處置。他看的是他留言的地方,回在別處他收不到。
處置有四種:照做、回覆說明為什麼不照做、拒絕、另外開單。照做不是預設。「處置」這個 字是中性的,而它常被讀成「照做」。一條意見可以是對的、而仍然不該在這一輪做;也可以是 這個 repo 根本不做那件事。讀成「照做」的話,這一條會把每一則意見都變成一次施工。
每一串都算,nit 也算。 「妥善處理」的量法是:每一串的最後一則是作者,而且那一則說出了 四種處置裡的哪一種。標成 nit 的意見只是提出者在說「這條不擋你」,不是在說「這條不用回」。
第 2 條有一個這個 repo 自己的教訓:2026-08-03,「開 PR」這個能力在腳本歸位時被刪掉,因為 它在三站裡沒有主人;43 分鐘後有人寫了一句跟腳本矛盾的散文把洞蓋住,於是「PR 算不算完成」 有兩個互相矛盾的答案在流通。現在它有主人了,就是這一條。
第 5 條是同一個形狀又發生了一次。2026-08-11:一個 PR 收到三位 reviewer 的意見,全部查證、 修掉、assertion 重跑全綠,也回了另一個系統的訊息串——但那個 PR 上一則回覆都沒有,是人自己 發現的(「我好像沒有在 PR 上看到你怎麼處理的」)。偵測有主人(誰欠我回覆、誰欠我審查, 都有東西在算),動作沒有主人。而「還沒被 approve」這個狀態,跟「我還沒動手」長得 一模一樣。
在程式碼裡,「你寫下的話」長成什麼樣子
engineering 那條「送審之前把自己寫下的話跟行為對一遍」在這一類工作裡有具體形狀。這幾樣
都是對行為的主張,都會跟實作分開演化,而編譯器與測試都不會抓:
- doc-comment 的第一句——通常是最早寫下的,也最可能是舊設計的化石。
- 型別宣告(參數型別、回傳型別、介面欄位)——宣告成數字就不能送出字串。它是契約, 不是提示;改了寫入路徑要回頭看宣告。
- 名字——一個叫「處理中」的狀態要真的在事情處理中的時候是真的。
- 形狀——把等待用的結構套在一個不會等待的呼叫上(例如包住一個同步呼叫),會讓一段 沒有等待的程式碼長得像在等待,而讀的人依樣相信它。
第 4 條那句「本機跑完跑得動的驗證」的另一半就是這個:跑得動的用跑的,跑不動的用讀的。 一句說謊的 doc-comment 跑不出紅燈,它只會在下一個人依它行事的時候生效。
所以寫的時候就要讓它值得被讀:
- 新增或修改的 function 要有 doc-comment(TSDoc / JSDoc / Google-style docstring / shell function header),說明它做什麼、參數與回傳值是什麼。既有沒動的不強制回填——上面 那句「最可能是舊設計的化石」講的就是它們,補一句猜出來的比空著糟。
- 帶業務意義的 literal 抽成有名字的常數,名字說不完的在宣告處補一行。名字也是一句
對行為的主張,同一條規矩適用。純視覺數值(padding、gap)與
aria-label這類不強制。 - Inline comment 只在「為什麼」不自明時寫,一短行。 逐行解說「做什麼」是會說謊的 雜訊:它貼著那一行,那一行改了它不會跟著改。
一個輸出寫得下幾種情況
上一節問的是「寫下的話跟行為對不對得上」。這一節問另一半:寫下的話的詞彙夠不夠用。
離場碼、給人看的訊息、記在檔案裡的欄位、報告的一行——每一種都是一組有限的值,而會走到 那一行的情況常常比那組值多。多出來的那一種不會消失,它會被折進寫得下的那幾種。
被折掉的幾乎總是同一種:這一趟沒量到、問不到、走不下去。 它跟「量到了,好的」與 「量到了,壞的」是三件事,而多數輸出只有兩格。
折向哪一邊不是設計選擇,是意外,由旁邊那幾行剛好怎麼寫決定:上一趟的結果檔還在,
於是比對照樣跑完,折成通過;一句 || die 把「工具不存在」的 127 接住,折成失敗;
查詢那條路在印任何東西之前就 exit 1,折成什麼都沒有。
三種都不是真話,但壽命不一樣。折向通過的沒有人會抱怨,它活到有人回頭核對為止; 折向失敗的會有人立刻去查,而他查的是錯的東西——訊息說這份交付壞了,真相在環境。
所以動手之前問一次:
會走到這一行的情況有幾種?這一行寫得下幾種?
一樣就沒事。不一樣的時候,差額已經在那裡了,只是還沒有人撞到。修法通常很小:多一個離場 碼、多一句話說出「這一趟沒量到」,或者在折進去之前先把原因印出來。
這份知識裡好幾處各自寫過它的一半——問不到不得當成沒問題。那些是這一句的實例,各自 貼著自己那一支腳本;反過來那一半同樣算數:問不到也不得當成你錯了。
這件事擋不住。 一道檢查得先知道「這一行本來應該寫得下幾種」,而那正是上面那個問題 要回答的東西——它只能在寫的時候被問一次。
寫腳本的時候
語言選擇:串工具、檔案操作、簡單 control flow → bash。結構化資料、複雜 regex、需要 在記憶體裡建資料結構 → python。只有裝了套件才跑得動 → 那個套件生態的語言。純檔案掃描不要 拉 Node,純 bash 能解的不要拉 Python——啟動成本會在整批一起跑的時候累積起來。
關卡門與對外寫入之間要有 fail-stop 邊界:set -euo pipefail,或者把檢查與寫入拆成兩次
呼叫,寫入只在檢查回 0 之後跑。一個檢查失敗了還繼續往下寫的腳本,比沒有那個檢查糟——它
會印出一行綠的。
開工前要成立的條件
第 1 條有一個時機問題:它必須在動手之前成立,事後才發現改動躺在預設分支上,那些 commit 已經在那裡了。所以它不是驗收時才量的東西,是開輪次那一刻的前置條件。
下面這一行是機器讀的。核心不認得「branch」是什麼——它只知道去找這一行、跑它指名的命令、 非 0 就拒絕開工。條件寫在這裡,不寫在核心裡:
有幾個落腳處就判幾次。 核心把這張單開輪次時宣告的那幾個地方原樣接在上面那行命令後面 (跟下一節的身分那一行同一條路),所以這支腳本判的是這張單的改動會落在哪,不是「跑這 個命令的人現在站在哪」。全部成立才成立:放行一個「三個地方裡有兩個對」的開工,等於第三個 地方的改動從第一個 commit 起就沒有被任何條件管過。
一個地方都沒被指名時它回非 0——那是「量不到」,不是「通過」。2026-08-08 之前核心跑這一行 不帶參數,於是一張改三個產品 repo 的單,開工條件是拿 workspace 自己的分支去判的;那個判定 跟改動落在哪完全無關,綠或紅都不代表任何事(同事在一張跨 repo 的單上撞到)。
2026-08-03 那次,三張單的 commit 全部混在預設分支上,而寫下這條規矩的 commit 本身也在 預設分支上——規矩在寫下的一小時內失效四次。靠人記得的規則的失效率就是那個樣子,所以 它現在有一道關卡。
第二條:那個工作區的 hook 真的被 git 在用
第 4 條「push 之前本機跑完跑得動的驗證」在很多 repo 裡是掛在 git hook 上的,而 hook 有一個 只有開工前擋得住的失效方式:沒接上的 checkout,它的每個 commit 與每次 push 一道關卡都不會 跑,而且不會有任何東西說。 發現的時候那些 commit 已經在歷史裡了。
所以開工條件多問一句。它不認得任何一個 hook 目錄的位置,也不認得誰負責裝它——它掃 宣告:
<!-- {任意前綴}-GIT-HOOKS: {相對於 repo 根的路徑} | {接上它的命令} -->
掃兩棵 skill 樹:那個工作區底下的 .claude/skills,以及使用者家目錄的
~/.claude/skills。個人的 skill 住在後面那一棵,而它們一樣宣告得出東西。
為什麼修法是多掃一棵樹,不是把宣告搬進工作區:一支宣告自己是個人的 skill,按分類規矩 本來就不會出現在工作區那一棵樹裡。這個框架自己的 workspace 就是這樣——那份唯一真的 GIT-HOOKS 宣告住在一支個人的 skill 裡,而個人的東西不進公司 repo。搬過去等於違反那條規矩, 所以只掃工作區那一棵的掃描器,對這一類宣告是結構上看不見,不是這一次剛好不在(DP-627)。
一份都沒找到時它說的是「這一次沒有問到」,而且說出掃了哪裡——不是「這一條不適用」。 兩句話對一個真的接上了 hook 的工作區給出相反的意思,而分辨得出來的只有讀到那句話的人。
一份宣告適不適用於這個工作區,看它指的目錄在不在這裡。 那是事實,不是推測:宣告裡的 路徑相對於 repo 根,而家目錄那一棵 skill 樹被這台機器上每一個工作區共用,所以住在那裡的 一份宣告不可能是「每一個 repo 都要有這個目錄」的意思。但住在工作區自己那一棵樹裡的宣告 不一樣:它只可能在講這個工作區,所以它指向空氣的時候仍然判紅。
一段講「宣告長什麼樣」的文字不是一份宣告。 分開它們的是位置:宣告是散文裡的一行 HTML
註解,格式說明住在 fenced code block 裡,掃描器跳過 code block。以前分開它們的是佔位符
剛好用了哪個字元——上面那個 {任意前綴} 的 { 不在 [A-Za-z0-9_-] 裡,所以正則對不上
它;把佔位符換成一個 ASCII 前綴,這段格式說明就會被當成一份活的宣告撿走。一個沒有人說出口
的分界,下一次換個寫法就沒了。
宣告了而且適用,就要三件都成立:目錄存在、裡面的檔案都有執行位元(沒有的話 git 是安靜地
跳過)、而且 core.hooksPath 真的指著它。問不出 core.hooksPath 的時候回「量不到」並拒絕,
不是放行——問不到關卡的狀態跟關卡是綠的,是兩件事。
施工期間:這個工作區還是不是原來那個
開工條件是一次性的事件,它問「有沒有站在預設分支上」——那是一個對所有單都一樣的判準。 切到另一張單的分支照樣通過它,而那正是真的會發生的事故:兩個 session 互搶同一個 checkout,commit 落到別人的 feature 分支上。
所以除了「開工前成立什麼」,還要有「這張單當初落在哪」。下面這一行也是機器讀的,它印出 的每一行核心當作不透明的識別存起來,之後任何一刻拿現況比對。核心不知道那是分支名, 比對相不相等不需要知道:
印幾行就是幾個地方。 一件工作牽涉多個 repo 是常態,所以給幾個路徑就印幾行,每一行是
<repo 目錄名>:<分支名>——帶目錄名是因為兩個 repo 上同名的分支(main)到處都是,只印
分支名會讓兩個地方塌成一個。
要量哪些地方是被告知的,不是猜的。 核心把這張單開輪次時宣告的落腳處原樣接在上面那行
命令後面,所以這支腳本不看自己站在哪裡;一個地方都沒被指名就回非 0,不印任何東西。
DP-482 之前它沒拿到參數就量 pwd,於是一張「單住在 A、程式碼落在 B」的單記下的是 A——
之後每一次比對都拿 A 跟 A 比,永遠自洽,而 B 被別的 session 切走時完全安靜。
求不出來(detached HEAD、不在 repo 裡)一律回非 0 而且一行都不印,核心會說「量不到」 而不是「一致」。印一半比不印糟:那會記下一個比實際涵蓋範圍小的集合,而它之後每次比對都 自洽。
動手刻之前:這件事這裡大概已經有人做過
engineering 列的三件要浮出來的事,第二件是「重造既有組件」。那條規矩在軟體工程現場最
常見的失效方式不是有人明知故犯,是判準被讀成「重寫一個大東西」——手刻一小段控制流在
寫的當下感覺像在寫 code,不像在造組件,於是什麼都不會觸發。
判準是行為重疊,不是行數。 八行也算。想做的事如果講得出名字,那個名字通常已經在這個 專案裡了,動手之前先找一次。
這幾種形狀特別容易被重造,因為每一個都小到不值得停下來想:
- 等上層或另一個元件把一件事做完
- 防抖、節流
- 輪詢、重試、退避
- 開了再關的載入/禁用狀態
- 分頁、無限捲動
- 表單驗證與錯誤訊息
- 彈窗的開關與生命週期
先找一次的成本是一次搜尋;沒找的成本是一個測試照不到的缺陷。
標本 2026-08-07:一顆按鈕要在上層做完之前轉圈,於是手刻了一段 try/finally 包住一個
同步的事件送出——狀態開了又關,中間沒有任何會離開這個 tick 的點,按鈕從來沒有轉過。那個
專案早就有一支八行的共用函式在解「把事件丟出去、等對方做完」這件事;掃過去,正確用法
18 處,手刻的那個形狀 0 處。八行對八行,而且沒有任何東西會紅——測試量的是「按下去有沒有
丟出事件」,加不加那層殼都一樣綠。最後是人在審查時抓到的。
量它
bash .claude/skills/swe-knowledge/scripts/check-swe-precondition.sh --repo <path> # 開工前
bash .claude/skills/swe-knowledge/scripts/workspace-identity.sh --repo <path> # 施工期間比對用
bash .claude/skills/swe-knowledge/scripts/check-swe-done.sh --repo <path> # 交付前
它查四件事:現在不站在預設分支上、這條 branch 有 open 的 PR、工作區是乾淨的、以及那個 PR
上 reviewer 提的意見都有處置回到那條意見上。查不到的(沒裝 gh、沒有 remote、遠端回非 0)
會說出來並回非 0,不會當成通過——量不到跟量到「沒問題」是兩件事。第 4 條它不查,那要
看是哪個專案的哪一套指令,屬公司 pack。
第 5 條算的是「串頭有沒有被作者接過話」:一串意見的所有回覆都指向那一串的第一則,所以問的 是「有沒有一則回覆的作者是 PR 作者」。另一位 reviewer 在同一串裡接話不算——把「這串有 人回過」當成成立的那一版,會讓兩個 reviewer 互相討論就把作者放行。
注一刀進去驗那條斷言真的會紅
要證明一條斷言真的在守,就要讓它紅一次。 而最直覺的注入方式——真的 commit 一顆帶著 問題的 commit——會撞上 commit 前的那道檢查,於是「繞過去」變成路上唯一看得見的那一步。 它不是唯一的一步。 這一節只回答一件事:不繞,怎麼做得到。一次紅控要成立需要什麼 是另一族問題,不在這裡——那一族的每一條各自已經有家,這裡不搬也不重講。
不要注在共用的那棵樹上,而理由是後果不是紀律。 那一刀讓那道檢查暫時壞掉幾秒,而那 幾秒裡別的人可能正在跑自己的交付。注在共用樹上的紅控會擋掉別人的交付,而那個擋看起來 會像他們自己的實作有問題——他們看到的是自己被擋下來,於是去查自己剛寫的東西,而正確的 下一步是等隔壁那一刀還原。同一個顯示對得上兩種真相,而兩邊的下一步相反。「開一棵新的 比較乾淨」不是理由,這才是。
兩種拋棄式的做法,各自換到什麼:
| 做法 | 換到什麼 |
|---|---|
| 一棵拋棄式的工作樹 | 便宜、幾秒就開得起來,但它跟原本那棵共用同一份 git 目錄 |
| 一棵完整複製出來的樹 | 完全隔離,代價是只在本機、沒有版控的設定不會跟過去——依賴那種設定的掃描類檢查會回「量不到」,而那個紅不是那一刀造成的 |
所以在一棵全新的樹上,正向控制多了第二個用途。 它原本回答的是「這條斷言不是恆紅」; 在新樹上它還回答這個紅是環境造成的,還是那一刀造成的——做法是拿掉注入、改動一個無關 的地方,再跑一次。同樣紅就是環境。少了這一步,一棵新樹自己的紅會被讀成注入的成果, 而結論剛好反過來。
一刀連帶紅了兩條斷言時,不要為了讓它只紅一條而去挑刀。 那通常是它們真的耦合:其中 一條吃的幾種形狀裡,有一部分走的是另一條的路。挑開之後那兩條看起來互相獨立,而它們不是。 這跟「一刀紅了整批、看不出哪一條在守」是相鄰的兩件事,方向相反——收窄過頭跟放寬過頭 各有一個坑。
還原要跟注入同粒度。 量完把那一刀拿掉,拿掉的範圍要剛好是那一刀——不是把那個檔案 整個還原,那會連帶丟掉同一個檔裡還沒送出去的東西。
所以順序是:先把這一輪的實作 commit 掉,再注那一刀。 反過來做的話,還原那一步會連 同還沒 commit 的實作一起丟掉——而那不是一個會報錯的動作,它成功地把東西刪乾淨。
回覆 reviewer
三步,順序不得顛倒:落地成檔案 → 過對外寫入的檢查 → 才送出去。送出去是不可逆的, 而一段沒有被任何東西看過的文字送到別人面前,收不回來。
第一步,把回覆寫成一個檔案。 直接把文字塞進命令列的那一種,沒有任何東西看得到它, 事後也指不出送了什麼。
第二步,過那道檢查。 它不住在這裡,由認領它的那支 skill 自己宣告:
bash .claude/skills/swe-knowledge/scripts/resolve-external-write-gate.sh
它回一行命令;沒有人宣告的時候它說出來並回非 0——那個離場碼的意思是「這個環境沒有那 一層,落地成檔案這件事你自己要做」,不是「不用檢查了」。這一支 skill 會被單獨匯進別人的 環境,而寫死一條指向別支 skill 的路徑,在那些環境裡是一個安靜讀不到的東西。
第三步才是送。 一行,跟〈開 PR〉同一個待遇:
gh api --method POST "repos/<owner>/<name>/pulls/<n>/comments/<comment_id>/replies" -f body=@<file>
這裡不帶包住那個 API 呼叫的腳本,理由跟〈開 PR〉那一段一模一樣。上面這是這份知識裡 唯一一個往別人看得到的地方送東西的動作——多開第二個,這三步就會有第二條繞得過去的路。
開 PR
gh pr create --repo <owner/name> --base <base> --head <branch> --title '<一行>' --body '<內容>'
沒有更多的了。這裡不帶自製的 PR 建立腳本:上一支長到 866 行,帶著 7 支 selftest,最後在 一次搬家裡整支消失而沒有人發現。
之後呢:它什麼時候算出去了
上面五條講的是 done——改動變成可被別人看見、可被判定的東西。再往後一步是「它出去了」, 而那一步在別人的 repo 裡不歸這條流程管。
判準一句話:那個 PR 被 approve 或被 merge,這條流程就走完了。 是哪一個由那張單自己說, 兩者都算——merge 常常是別人按的、按在別人的排程上,硬等 merge 等於讓一張已經被接受的單卡 在一個不歸我們管的動作上。
但「誰按」是公司 pack 要回答的問題,這裡不預設是別人。 公司 pack 說幾票算看過了、到了 誰按;它說作者自己按的,那麼票到了、第 5 條成立、CI 綠,按 merge 就是這條流程 最後一個動作,不是一個要問人的待決。2026-08-30 到 09-03 之間,同一個人把「達成的就 merge」 講了四次,每一次都是因為讀這一段的人只帶走「approve 或 merge 都算」、把行為者那一格留給 「不可逆先問人」的預設去補。
之後那個 repo 怎麼釋出,不由這裡管、不由這裡偵測、不由這裡記錄。 每個 repo 有自己的 節奏,而我們不是它的 owner。猜別人的節奏比不做更危險:一個猜錯的釋出狀態,讀起來跟一個 量到的釋出狀態一模一樣。要有設計的話,由那個 repo 的擁有者自己帶進來。
上線更不在這裡。 交付之後的上線要人介入,回填的做法是人主動說,然後動手改該改的地方。 這裡不加欄位、不加狀態、不加輪詢——沒有欄位就沒有東西會把「還沒回填」讀成「沒上線」或 「上線了」。
下面這一行是機器讀的。核心不認得 PR 是什麼,它只知道去找這一行、跑它、讀它印出來的狀態:
跟開工條件同一條路:核心把這張單記下的落腳處與身分原樣接在後面,所以判的是這張單自己 落在哪、當初開在哪條分支上,不是「跑這個命令的人現在站在哪」。有幾個落腳處就判幾次, 全部走完才算走完——放行一個「兩個 repo 裡有一個出去了」的終局,等於另一個永遠不會有人再看。
問不到不是走完,也不是沒走完:它自己一種狀態,印出來、非 0。一個因為 API 逾時而被讀成 「還沒出去」的單,會安靜地停在待辦裡;被讀成「出去了」的那一種更糟。
不做了之後:收拾自己留下的痕跡
一張單決定不做了,它在版控上留下的東西不會自己消失。2026-08-08 DP-440 關單並寫下理由, 它的 branch 與 PR 一路活到 2026-08-10 被人工看見才處理——「這張單不做了」與「還有一條 branch 在等著被合」同時成立了兩天,而沒有任何東西回報。狀態改了,痕跡沒改。
判準一條:收得掉的只有這張單自己的東西。
- 這張單開的那個 PR 關掉,遠端那條分支一起刪。commit 留在
refs/pull/<n>/head上,所以 關掉不等於丟掉——復原是git fetch origin refs/pull/<n>/head。 - 本地那條 branch 只有已經併進預設分支的才刪。還沒併的留著並且列出來:那裡面有沒有人 看過的工作,而刪掉跟丟掉之間只差一個 reflog 到期。
- 別人開的 PR、不是這張單的 branch,一律不動。
留下來的不得安靜。 收不掉的逐個列出來並且非 0——一個沒有被列出來的殘留,下一次就會被 當成沒有殘留,而那正是那兩天發生的事。
跟公司 pack 的關係
這裡是通識,只有上面五條;一家公司或一個專案特有的東西(codecov 門檻、stage 部署、 ticket 命名、review 人數)在它自己的 pack 裡。
兩者疊起來的時候要說出順序,不得靜默疊加:先這裡的五條,再疊公司 pack;公司 pack 只能 往上加條件,不能拿掉這裡的任何一條。真的需要拿掉時,那是一個要被說出來的例外,不是把 這一條默默改寫。理由跟「載不到就停」是同一句話——一個沒被說出來的合併,跟沒有合併,在 出事的時候長得一樣。