iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

Day 22-27 把系統做成了可以交付的形式:審查報告、PDF、命令列工具、Docker、Compose,還有 Day 27 剛加入的 OLLAMA_NUM_CTX 與 OLLAMA_TIMEOUT。功能齊了,但要用起來,使用者得翻遍 27 篇文章才知道:

  • 有哪些環境變數、預設值是什麼
  • CLI 有哪些參數、結束碼代表什麼
  • 遇到「無法連線到 Ollama」「找不到中文字型」該怎麼辦

今天寫一份完整的使用指南 docs/USAGE.md。但寫文件只是一半,另一半是更常被忽略的問題:文件怎麼跟著程式一起更新。這個系列一路走來最常修的,正是「文章寫的和程式做的不一樣」。所以今天也寫一組測試,讓文件和程式不一致時,測試直接失敗。

這一天在系列中的位置

第 4 週:發佈與開源

  • Day 24-26 → 命令列工具、Docker、Docker Compose
  • Day 27 → 大規模性能基準
  • Day 28 → 使用文檔與同步檢查 ← 今天
  • Day 29 → API 文檔與範例
  • Day 30 → 開源發佈

今日目標

讀完這篇後,你會完成:

  1. 使用指南 docs/USAGE.md:安裝、CLI、Web、Docker、18 個環境變數、疑難排解、準確度參考
  2. 同步檢查 tests/test_day28_docs.py:6 個測試,雙向比對文件與程式
  3. 驗證測試真的有效:故意讓文件過時,確認測試會失敗

問題背景:文件為什麼會過時

文件過時通常不是因為沒人寫,而是因為改程式的人沒有被提醒。回顧這個系列:

天 程式改了什麼 沒跟著改的文件
Day 13 API 端點 文章仍寫 /api/analysis
Day 15 登入用表單格式 文章仍寫 JSON
Day 17 新增 /refresh API 說明沒有列出
Day 21 新增 SRS_SELF_CHECK 只寫在當天的文章裡,沒有使用者文件

所以文件裡可以被機器檢查的部分,就應該讓機器檢查:

文件內容 程式中的真實來源
環境變數表 程式中所有 os.getenv("...")
CLI 參數表 src/cli.py 的 argparse 定義
結束碼 EXIT_OK、EXIT_INPUT_ERROR、EXIT_FAIL_ON
提到的檔案路徑 檔案系統
範例指令 實際執行

說明文字、設計理由這些寫給人看的內容,機器檢查不了,仍要靠人。


實現方法

docs/USAGE.md
  1. 選擇使用方式   2. 安裝   3. CLI   4. Web   5. Docker Compose
  6. 設定(環境變數表)   7. 疑難排解   8. 準確度參考
        ↑ 雙向比對
tests/test_day28_docs.py
  ├─ 程式的 os.getenv  ⊆ 文件的環境變數表        (程式加了變數,文件要寫)
  ├─ 文件的環境變數表  ⊆ 程式 + compose + 前端   (程式刪了變數,文件要刪)
  ├─ 文件的 CLI 參數表 = argparse 定義
  ├─ 文件的結束碼表   = 程式常數
  ├─ 文件提到的檔案都存在
  └─ 文件中的離線範例指令可以執行
  • 輸入:docs/USAGE.md 與專案原始碼
  • 輸出:測試結果
  • 檔案:docs/USAGE.md、tests/test_day28_docs.py(新增)
  • 下游:Day 29 的 API 文件用同樣的方式與 OpenAPI 規格比對;Day 30 的 CONTRIBUTING.md 要求貢獻者遵守這些檢查

專案結構變化:

srs-review-agent/
├── docs/
│   ├── Day-*.md             ← 每一天的實作紀錄
│   └── USAGE.md             ← 【新增】使用指南
└── tests/
    └── test_day28_docs.py   ← 【新增】6 個測試

今天不需要新的套件。


代碼示例

1. 使用指南的結構

docs/USAGE.md 依「使用者會遇到的順序」安排,而不是依「系列的天數」:

## 1. 選擇使用方式
| 方式 | 適合 | 需要 |
| 命令列(CLI) | 分析一份規格書、接進 CI | Python 3.12+ |
| Web(API + 前端) | 團隊共用、多人登入、保存歷史 | Python、Node.js 18+ |
| Docker Compose | 一次部署整套系統 | Docker |

讀者先決定用哪一種,再跳到對應的段落。幾個特別花心思的地方:

  • 規格書格式放在 CLI 段落的最前面,並明確寫出「編號後面不是冒號的說明文字不會被當成需求」,這是 Day 24 抽取規則最容易讓人困惑的地方
  • 環境變數表一次列出全部 18 個,包含只在 Compose 用的 COMPOSE_OLLAMA_URL 與只在前端打包時用的 VITE_PUBLIC_MODE
  • 疑難排解直接貼出程式實際印出的錯誤訊息。使用者通常是拿著錯誤訊息來找文件的,原文可以直接搜尋到
  • 準確度參考同時寫出 F1 0.897 與它的限制,並連到 7.4「大型規格書出現大量誤報」。只寫好看的數字,會讓使用者在第一次用真實規格書時失望

2. 從文件中取出段落

建立 tests/test_day28_docs.py。先寫一個取段落的工具:

USAGE = (ROOT / "docs" / "USAGE.md").read_text(encoding="utf-8")


def _section(title: str) -> str:
    """取出某個 ## / ### 標題底下、到下一個同級以上標題為止的內容"""
    # 只把 ## / ### 當成段落標題;程式碼區塊裡的 bash 註解(# ...)與 Markdown 範例的 # 標題不算
    match = re.search(rf"^(##+) {re.escape(title)}\n(.*?)(?=^#{{2,3}} |\Z)", USAGE, re.M | re.S)
    assert match, f"找不到段落:{title}"
    return match.group(2)

註解裡那一行是測試第一次執行失敗後才加上的:一開始把所有 # 開頭的行都當成標題,結果程式碼區塊裡的 bash 註解 # 只用規則,報告印到標準輸出 被當成新段落,範例指令全部被切掉,測試回報「沒有找到範例指令」。用 Markdown 寫的文件,程式碼區塊裡也會有 #。

3. 環境變數:雙向檢查

def _env_vars_in_code() -> set:
    names = set()
    for path in (ROOT / "src").glob("*.py"):
        names |= set(re.findall(r'os\.getenv\(\s*"([A-Z_]+)"', path.read_text(encoding="utf-8")))
    return names


def _env_vars_in_docs() -> set:
    return set(re.findall(r"^\| `([A-Z_]+)` \|", _section("6. 設定(環境變數)"), re.M))


def test_env_vars_documented():
    """測試 1:程式讀取的環境變數全部寫進文件"""
    missing = _env_vars_in_code() - _env_vars_in_docs()
    assert not missing, f"文件沒有列出:{sorted(missing)}"

正規表達式中的 \s* 是為了 src/database.py 那種跨行寫法:

DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "sqlite:///./test.db"  # 開發默認
)

反方向的檢查防止文件過時:

def test_documented_env_vars_exist():
    """測試 2:文件列出的環境變數都真的有被使用(避免文件過時)"""
    other_sources = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + \
        (ROOT / "frontend" / "index.jsx").read_text(encoding="utf-8")
    stale = {name for name in _env_vars_in_docs() - _env_vars_in_code() if name not in other_sources}
    assert not stale, f"文件列出但沒有任何地方使用:{sorted(stale)}"

COMPOSE_OLLAMA_URL 只出現在 docker-compose.yml,VITE_PUBLIC_MODE 只出現在前端,所以這兩個來源也要一起搜尋。

4. CLI 參數:與 argparse 比對

def _parser_options(command: str) -> set:
    sub = next(a for a in build_parser()._actions if a.dest == "command").choices[command]
    return {opt for action in sub._actions for opt in action.option_strings if opt not in ("-h", "--help")}


def test_cli_options_match_parser():
    """測試 3:CLI 參數表與 argparse 定義完全相同"""
    # 反引號內可能帶選項值,例如 `--format md\|json\|pdf`
    analyze_doc = set(re.findall(r"^\| `(-[-\w]+)[^`]*`(?:, `(--[-\w]+)`)?",
                                 _section("3.3 參數").split("`extract`")[0], re.M))
    analyze_doc = {o for pair in analyze_doc for o in pair if o}
    ...
    assert analyze_doc == _parser_options("analyze"), \
        f"analyze 差異:文件多 {analyze_doc - _parser_options('analyze')},程式多 {_parser_options('analyze') - analyze_doc}"

Day 24 的 build_parser() 在這裡派上用場:把建立 parser 獨立成函數,測試就能直接拿到參數定義,不必解析 --help 的輸出。

第一次執行時這個測試失敗了,訊息是「程式多 {'--format', '--fail-on'}」:文件裡寫的是 `--format md\|json\|pdf`,反引號裡除了參數名還有選項值,原本的正規表達式沒有涵蓋。文件本身沒錯,是測試要能讀懂文件的寫法。

5. 檔案路徑與範例指令

def test_referenced_files_exist():
    """測試 5:文件中提到的專案檔案都存在"""
    paths = set(re.findall(r"`((?:tests|docs|src|frontend)/[\w./-]+\.\w+)`", USAGE))
    paths |= {"docs/" + p for p in re.findall(r"\]\(([\w-]+\.md)\)", USAGE)}
    paths |= {"docker-compose.yml", ".env.example", "requirements.txt", "Dockerfile"}
    missing = [p for p in sorted(paths) if not (ROOT / p).exists()]
    assert not missing, f"文件提到但不存在:{missing}"


def test_offline_examples_run(capsys):
    """測試 6:3.1 / 3.2 中不需要 Ollama 的範例指令可以執行"""
    commands = re.findall(r"^python3 -m src\.cli (.+)$",
                          _section("3.1 規格書格式") + _section("3.2 分析"), re.M)
    offline = [c for c in commands if "--ollama" not in c and "--format pdf" not in c and " -o " not in c]
    for command in offline:
        args = command.split("#")[0].split()
        assert main(args) == EXIT_OK, f"範例指令失敗:{command}"

範例指令直接呼叫 Day 24 的 main(argv),和使用者在終端機打的是同一條路徑。需要 Ollama、會寫檔案的範例則略過,避免測試依賴外部服務或留下檔案。

test_referenced_files_exist 也抓到一個問題:我一開始在 USAGE.md 寫了「端點說明見 docs/API.md」,但那份文件明天 Day 29 才會建立。今天先拿掉這個連結,明天建立 API 文件時再加回來,屆時這個測試會確認連結有效。


驗證結果

測試

cd srs-review-agent
python3 -m pytest tests/test_day28_docs.py -v
tests/test_day28_docs.py::test_env_vars_documented PASSED                [ 16%]
tests/test_day28_docs.py::test_documented_env_vars_exist PASSED          [ 33%]
tests/test_day28_docs.py::test_cli_options_match_parser PASSED           [ 50%]
tests/test_day28_docs.py::test_exit_codes PASSED                         [ 66%]
tests/test_day28_docs.py::test_referenced_files_exist PASSED             [ 83%]
tests/test_day28_docs.py::test_offline_examples_run PASSED               [100%]

============================== 6 passed in 0.09s ===============================

測試真的抓得到問題嗎

一組永遠通過的測試沒有意義。用兩種方式確認:

1. 故意讓文件過時:從 USAGE.md 刪掉 SRS_SELF_CHECK 那一列再跑:

E       AssertionError: 文件沒有列出:['SRS_SELF_CHECK']
FAILED tests/test_day28_docs.py::test_env_vars_documented

還原後 6 個測試全部通過。

2. 真的發生過:Day 27 為了處理 prompt 截斷,在 OllamaLLM 加入了 OLLAMA_NUM_CTX 與 OLLAMA_TIMEOUT。撰寫 USAGE.md 時,我一開始是照著前幾天的筆記列環境變數,漏掉了這些最新的變數,測試立刻指出:

E       AssertionError: 文件沒有列出:['OLLAMA_NUM_CTX']

OLLAMA_TIMEOUT 也是同樣的情況。補上之後,順便在疑難排解 7.4 加入 Day 27 的截斷警告,以及 OLLAMA_NUM_CTX、OLLAMA_TIMEOUT 的用法。這正是這組測試存在的理由:新增功能的人不必記得去改文件,測試會提醒他。


權衡與限制

  • 只檢查結構化的部分:環境變數、參數、結束碼、路徑、範例指令。「預設值寫錯」「說明文字過時」仍然檢查不到,例如有人把 SESSION_INACTIVITY_MINUTES 的預設值改成 60,表格裡寫的 30 不會被發現
  • 測試綁定文件格式:段落標題、表格寫法改了,測試要跟著改。這是用「可以被檢查」換來的代價
  • 範例只跑離線的:需要 Ollama 的範例沒有自動測試,靠 Day 24-27 的手動驗證
  • 使用者只能讀中文:文件與錯誤訊息都是繁體中文。若要國際化,需要另一份英文文件,同步檢查也要涵蓋它

提交變更

git add docs/USAGE.md tests/test_day28_docs.py .env.example
git commit -m "Day 28: 使用指南與文件同步檢查"

明天預告

USAGE.md 寫給使用系統的人;要串接 API 的開發者還需要另一份文件:每個端點的請求與回應、認證流程、錯誤格式,以及可以直接執行的範例程式。明天 Day 29 我們匯出 OpenAPI 規格、撰寫 API 文件與 Python / curl 範例,並用同樣的方式讓 API 文件與程式自動保持一致。


上一篇
Day 27:大規模性能基準
下一篇
Day 29:API 文檔與範例
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言