規則文件、操作手冊、範本這類文字文件的拆分,判斷依據與程式模組相同:一份文件被幾種互不相干的主題修改,就承擔了幾種職責。範圍包括從修改紀錄讀出職責、沿哪條軸切開,以及拆完之後原檔與引用的處理;文件放在版本控制裡;放在 wiki 的文件用頁面歷史代替修改紀錄,wiki 的編輯摘要多半是空的,歸類改成讀每次修改的差異落在哪幾節,找引用改用 wiki 本身的反向連結功能。

程式模組要不要拆、依什麼切在從修改紀錄畫模組邊界;拆完之後保留原本入口的程式做法(改成同名套件並重新匯出,對應本篇的原檔導覽表)在拆分 Python 模組,文章與系列之間的責任分配在 SOLID 寫作方法論。一份文件只剩一種職責之後,內部怎麼把每次都要讀的內容與按需查閱的細節分層,是規則文件的精簡的主題,那一篇的案例是 AI 代理人每次都整份讀進來的規則文件。

修改紀錄裡的職責訊號

單一職責原則把職責定義成「改變的理由」,Robert C. Martin 把「理由」對到提出變更的人或角色。這個定義對文件同樣成立:一份文件被一個不相干的主題修改——另一群人提出、要求另一種改動——就多一個改變它的理由。SOLID 寫作方法論的變動理由測試要作者預測「這篇會因為什麼事件需要更新」,版本歷史記的是實際發生過的更新;把預測與紀錄並排,紀錄裡有而預測沒有的那些主題,就是預測漏掉的修改來源。

文件的 commit 多半不帶需求編號,所以修改的主題要靠讀 commit 標題人工歸類;標題讀不出主題時(「docs: update」),改看那次修改的差異落在哪幾節,squash merge 把幾個主題壓成一筆時,同一個 commit 可能要歸進好幾類。這套歸類方法的推導,以及用在專案規則檔上的做法,整理在 report 的文件的職責數從修改紀錄讀出:

1# 列出一份文件近半年的修改,每行是 commit 短 hash 與標題
2# --follow:文件改過檔名時,改名之前的紀錄也一起列出
3git log --follow --since="6 months ago" --no-merges --format='%h %s' -- docs/deploy.md

--since 換成要回看的時間範圍,最後的路徑換成要檢查的文件。範圍太短看不出重複出現的主題,太長會混進早已不存在的職責。起點可以從文件建立那天開始,讀完整段歷史列出主題,再看最近半年是不是還有新的主題出現;範圍至少要包含一次系統或組織的異動,文件存在以來沒有任何異動時,就讀它的整段歷史;修改頻率低的文件要拉得更長。假設一份部署手冊的修改紀錄是這樣:

1a1f3e2c 新增 staging 環境的環境變數
29c0d7b1 回滾步驟改成先切流量再降版
35e8a442 值班聯絡人換成新的輪值表
4d27c9f0 權限申請改走新的簽核表單
53b61aa8 補上 production 的環境變數預設值

五次修改落在四個主題:設定參考(環境變數)、回滾程序、值班聯絡、權限申請。四個主題的修改節奏各自不同——聯絡人隨人員異動改、程序隨系統架構改、設定隨服務的版本改、權限申請隨公司的簽核規定改——所以它們會在不同的時間、由不同的人改同一份檔案。git 能自動合併不同段落的修改,代價出現在兩個地方:兩個人在相鄰位置修改或追加時(例如都在聯絡人清單尾端加一列),合併衝突照樣發生;這份文件的審查者要同時熟悉四個主題,才審得了每一次修改。

合併衝突與審查負擔都不存在時,拆分換不到這兩方面的改善:只有一個人維護、一年只改幾次的文件,沒有人跟他撞同一處,也沒有別的審查者需要分工,而拆分之後的導覽與引用維護照樣要付。讀者那一端的收益仍然存在——讀者每次只需要其中一個主題時,拆開之後他只讀相關的那一份——所以單人維護、很少修改的文件是否拆分,看讀者是不是每次只查一個主題。受文件控制的文件(SOP、受稽核的程序)還有兩項成本:拆分本身是一次要走核准程序的變更,拆出來的每一份都要各自核准;拆分之前的修改紀錄留在原檔的路徑下,git log --follow 查新文件多半只看得到拆分那一筆,拆分的 commit 要寫出哪一節搬到哪一份,修訂紀錄才接得起來。反過來,拆出來的某一份保留了原檔過半的內容時,--follow 會把它當成原檔改名而來,把原檔全部的歷史(包括其他主題的修改)算進它名下。

長度與職責作為拆分依據的差別

文件的長度影響閱讀負擔,而拆分的軸由職責決定。兩份文件可以給出相反的結果:一份 600 行的設定參數表只描述同一份設定的欄位,讀者逐列查詢,它只有一種職責;一份 80 行的 README 同時寫安裝方式、貢獻規則、發版流程與聯絡窗口,它有四種。依長度切會把參數表切成幾段,讀者要在幾份檔案之間找一個欄位;README 則因為夠短而留在原地繼續累積主題。

集中登記型文件要另外處理。CHANGELOG、索引頁、術語表的職責就是集中列出所有項目,每個主題都要在這裡登記一筆,所以被許多主題修改是它的設計目的。依主題把 CHANGELOG 拆成幾份,每次登記仍然要改其中一份,兩個人都在同一份的尾端追加一筆時照樣衝突。降低衝突要讓每次登記不碰同一個檔案:每個項目一個片段檔,由產生器合併(towncrier 這類工具讓每個變更各寫一個片段檔,發版時再合成 CHANGELOG)。不想改成片段檔的逐行清單,可以在 .gitattributes 對它設 merge=union:兩邊在同一處追加的行,合併時都會保留、不產生衝突;代價是真正的衝突也不會出現標記,重複的行與順序要另外檢查;這個設定只在本機的 merge 與 rebase 生效,代管平台的網頁合併不一定讀它。

文件職責的切法

把修改紀錄歸類之後,每一類主題是一份候選文件。下面四種是文字文件常見的職責,可以當歸類的起點。前三種改變的理由各不相同,設計理由多半跟著它說明的程序:

  • 適用範圍與觸發條件:什麼情況要照這份文件做。隨組織的規定改。
  • 操作程序:怎麼做。隨工具與系統架構改。
  • 設計理由與取捨:為什麼這樣做。很少改,而設計理由改的時候,通常是它說明的那個程序也要改,設計理由與程序的改變理由相同,所以多半跟程序放在同一份;獨立成決策記錄(ADR)時才單獨一份。
  • 參考表:參數、欄位、對照表。隨被描述的系統版本改。

部署手冊的例子依修改紀錄裡的主題切成設定參考、回滾程序、值班聯絡與權限申請四份,各自只在自己的主題變動時被修改。設定參考對應參考表、回滾程序對應操作程序,值班聯絡與權限申請不在四種裡,它們是這份手冊自己的主題,照樣各自是一種職責。

程序型內容有一個切法上的限制。依讀者角色切(每一份只寫某一種讀者要做的步驟,例如開發者一份、值班工程師一份)會讓每一份各自從第一步開始編號,而子文件的後段步驟常常就是全程的中段,讀者走完子文件的最後一步會以為整個程序結束了。這裡的角色指讀文件的人,與判斷職責時提出變更的角色是兩件事。程序依步驟切,每一份就是全程的一段,編號只有一套;讀者角色軸適合查表型的內容,那裡沒有一套貫穿全程的步驟編號會跟子文件重複。

拆分後原檔的導覽表

原檔的路徑已經被其他文件的連結、書籤、腳本與 AI 代理人的指令引用,刪掉它之後這些引用失效:連結回 404 時讀者還看得到,靜默的是代理人找不到檔案就照自己的猜測繼續做。所以原檔留下來,改成一張導覽表(指向各份新文件的索引),每一列寫讀者要完成的一件事、對應的新文件,以及那份文件涵蓋什麼:

1# 部署手冊
2
3| 要做的事                   | 文件                                 | 涵蓋                             |
4| -------------------------- | ------------------------------------ | -------------------------------- |
5| 查某個環境變數的意義與預設 | [設定參考](deploy-config.md)         | 各環境的變數、預設值與生效方式   |
6| 部署出錯要回滾             | [回滾程序](deploy-rollback.md)       | 切流量、降版與確認的步驟         |
7| 找當班的人                 | [值班聯絡](on-call.md)               | 輪值表的位置與升級路徑           |
8| 申請部署權限               | [權限申請](deploy-access.md)         | 簽核表單與審核時程               |
9| 確認什麼時候可以部署       | [部署政策](deploy-policy.md)         | 變更視窗與適用範圍               |

導覽表依讀者要完成的事排列,因為讀者帶著一件要做的事來到這個檔案。只列檔名的導覽表要讀者逐份點開才知道去哪一份,「涵蓋」這一欄讓他在原檔就能判斷。

專案外的書籤與其他 repo 連到的是原檔的段標錨點(deploy.md#回滾),這一類多半找不出來,也改不到(公開 repo 裡的引用有些用程式碼搜尋找得到)。原檔保留舊的段標當錨點,每個段標底下放一行連到新文件的連結,從外面連進來的人落在那一行,再點一次就到。原檔被外部的程式以 URL 讀取內容、而不只是讓人點連結時(例如其他 repo 的建置腳本抓這份規格、代理人指令寫著「讀這個 URL」),原 URL 的內容換成導覽表,它們拿到的就不再是規格,也不會有任何訊號;被外部程式讀取內容的文件,原檔不換成導覽表:舊內容在原 URL 凍結或以版本號保留,新結構另發一版,等外部遷移完再換成導覽表。

跨文件共用規則的定義位置

拆分之後,同一條規則常會被幾份新文件用到——例如「所有部署都要在變更視窗內進行」同時影響回滾程序與權限申請。把這條規則複製進每一份讀起來方便,而之後修改規則的人通常只改到其中一份,幾份文件從此各說各的。規則定義在它所屬職責的那一份文件裡,其他文件引用那個段落。變更視窗是一條適用範圍的規則,而依修改紀錄拆出來的四份都沒有寫適用範圍。規則所屬的職責在拆分結果裡沒有住址時,先決定那個住址,再寫規則:這裡多拆一份 deploy-policy.md 寫部署的適用範圍與時段限制,變更視窗定義在那裡,回滾程序與權限申請連過去,原檔維持只有導覽表(上面的導覽表已經列了這一份)。

拆完之後的引用更新

拆分讓原檔的路徑與段標錨點失去了內容,而指向它們的引用散在整個專案裡,其中錨點那一種失效時不會報錯。找出這些引用有三條搜尋:

1# 提到原檔檔名的所有檔案;-r 遞迴搜尋,-l 只列檔名。用檔名搜尋,../deploy.md 這類相對路徑也抓得到
2grep -rl --exclude-dir=.git 'deploy.md' .
3
4# 連到原檔某個段標的連結;-n 讓 grep 在每筆命中前印出行號,逐一改到新文件
5grep -rn --exclude-dir=.git 'deploy.md#' .
6
7# 新文件裡的同檔錨點(原檔內部的 [x](#段標)),段落搬到別份之後要逐條確認目標還在同一份
8grep -rn '](#' deploy-config.md deploy-rollback.md on-call.md deploy-access.md deploy-policy.md

deploy.md 換成被拆分的那份文件的檔名,最後一條的檔案換成拆出來的新文件。找同檔錨點的那一條只抓得到 [文字](#段標) 這種寫法,reference-style 的 [x]: #段標 與 HTML 的 <a href="#段標"> 要另外搜;新文件放到不同目錄時,搬過去的相對路徑連結也要逐條確認。專案裡有 node_modules 這類不需要掃的目錄,各加一個 --exclude-dir。用檔名搜尋會命中一些不是連結的提及,每一筆命中要先確認它是連結,再改。文件站用不帶副檔名的路由寫連結(/docs/deploy/)時,另外搜一次路由的形式。

段標錨點的失效要特別看:段落搬到新文件之後,deploy.md#回滾 這種連結仍然打得開原檔,只是錨點已經不存在,瀏覽器停在頁面頂端而不報錯;連結檢查工具也不一定比對錨點,例如 lychee 預設不檢查,要加 --include-fragments。找同檔錨點的那一條搜尋處理的是同一種失效的另一個方向:原檔裡的 [環境變數](#環境變數) 跟著回滾那一段搬進 deploy-rollback.md,而〈環境變數〉那一段去了 deploy-config.md,這個錨點在新檔裡指向不存在的段落,要改成 deploy-config.md#環境變數。

拆分還會在檔與檔之間造出新的缺陷:新文件之間的互相指引缺了一份、某個詞的定義留在一份而使用在另一份、散文裡的「下面三節」在拆分後已經不是三節。這幾種缺陷在每一份單獨讀的時候都看不出來,形態與檢查方式整理在拆分產生的缺陷落在檔與檔的交界。

拆分之後的追蹤

拆分的結果可以用同一份修改紀錄驗證:過一段時間讀每一份新文件的 commit 標題,確認它只被自己那個主題修改,也確認一個主題的修改沒有同時改到兩份新文件——同一個 commit 同時改到兩份,代表當初把同一個主題切成了兩份。某一份新文件很快又出現多個主題的修改時,把那些修改再歸類一次——落在拆分時已有的幾個主題,是當初的歸類切錯了位置,回到修改紀錄重新切;出現拆分時沒有的主題,是這份手冊多了一種新的職責,例如部署流程加上了合規稽核的步驟;每次修改都是登記一筆,代表那份文件本身是集中登記型文件,改成片段檔或設 merge=union。