他請 Claude Code 串接 Stripe 收款,AI 一口氣產出 300 行,測試也全綠,他正準備 git push 上線
Sphinx 是一個免費、開源(FOSSFree and Open Source Software,免費且原始碼公開的軟體,任何人都能檢查與修改)的工具,專門給用 AI 寫程式的人。它的核心是一個 Git hookGit 在特定動作(如提交、推送)發生前後自動執行的小程式,可以當作關卡:你(或你的 AI 助理)執行 git push 時,Git 會先暫停,由 Sphinx 讀你這次改了什麼,然後問你一個問題。
它問的不是「這行程式是什麼意思」這種背書題,而是「出錯時會怎樣」的情境題。素材裡的例子是:你的 webhook別人的系統在事件發生時,主動呼叫你網址通知你的機制,例如 Stripe 在客人付款後通知你 處理器現在會先存下事件 ID 再呼叫 charge()(收款函式)。Stripe 逾時後又送來同一個事件,這時會怎樣?如果 charge() 失敗了,下一次重送又會怎樣?答案藏在程式的執行順序裡:先記 ID 再收款,收款失敗後 ID 已被記下,重送就被當成『處理過了』而跳過,客人永遠不會被收款。
回答的流程像一場小對話:答錯,審查者告訴你為什麼錯,再給你一個新情境;答對,推送才繼續。審查者是另一個 LLM大型語言模型,也就是 ChatGPT、Claude 這類會讀寫文字的 AI,而且它『沒看過你和 AI 助理的聊天紀錄』,只看最終的 diff(diff兩個版本之間的差異清單,標示哪些行被新增、哪些被刪除)。這很關鍵,後面的工程視角會說明原因。
它可搭配 Codex、Claude Code、Cursor、OpenCode、Pi,或純 Git 使用;用 npm install -g sphinx-understand 安裝,再執行 sphinx install 把 Git 與你的 AI 工具接起來。官方也承諾:你的答案不會回傳給寫程式的 AI,.env 與金鑰檔不會被打開。趕時間可用 sphinx yolo 放行,但每次略過都會被記錄。
| 做法 | 誰在檢查 | 擅長發現 | 主要缺點 |
|---|---|---|---|
| 直接信 AI、測試全綠就推 | 沒有人 | 已被測試覆蓋的問題 | 你完全不理解自己上線的東西 |
| 傳統人工 code review | 同事 | 設計與風格問題 | 個人專案或接案常常沒有同事可審 |
| Sphinx 推送前提問 | 不知情的 AI 審查者+你本人 | 你『不懂』的失敗情境 | 多花幾十秒;題目品質取決於審查模型 |
這是我們在實作中做的迷你版 Sphinx 的核心。它不用 AI,只用關鍵字規則出題,目的是讓你看清楚『Git 關卡+提問+放行或擋下』的骨架。
def added_lines(): r = subprocess.run(['git', 'diff', 'HEAD~1', 'HEAD', '--unified=0'], capture_output=True, text=True, encoding='utf-8', cwd=ROOT) return ' '.join(l[1:] for l in r.stdout.splitlines() if l.startswith('+') and not l.startswith('+++'))def ask(prompt): tty = open('CONIN$' if os.name == 'nt' else '/dev/tty', encoding='utf-8') except OSError:
return '' if os.environ.get('SPHINX_YOLO') == '1': f.write(datetime.datetime.now().isoformat() + ' 略過檢查\n') rule = next((r for r in rules if r['keyword'] in code), None) if ask('你的答案(輸入數字):') == str(rule['answer'] + 1):
return 0
return 1這是一個真實小專案:下載(或複製)檔案,照步驟在你電腦上跑起來。
#!/bin/sh
# Git 會把推送資訊從標準輸入灌進來,我們用不到,先丟掉;提問改由 Python 直接讀終端機
cat > /dev/null
python .githooks/mini_sphinx.py
import datetime, json, os, pathlib, subprocess, sys
sys.stdout.reconfigure(encoding='utf-8') # Windows 主控台預設不是 UTF-8
HERE = pathlib.Path(__file__).resolve().parent
ROOT = HERE.parent
def added_lines():
r = subprocess.run(['git', 'diff', 'HEAD~1', 'HEAD', '--unified=0'], capture_output=True, text=True, encoding='utf-8', cwd=ROOT)
return ' '.join(l[1:] for l in r.stdout.splitlines() if l.startswith('+') and not l.startswith('+++'))
def ask(prompt):
# git 把 stdin 拿去傳推送資訊了,所以直接讀終端機
try:
tty = open('CONIN$' if os.name == 'nt' else '/dev/tty', encoding='utf-8')
except OSError:
return '' # 讀不到終端機就當答錯,寧可誤擋也不默默放行
print(prompt, end='', flush=True)
return tty.readline().strip()
def main():
if os.environ.get('SPHINX_YOLO') == '1':
with (ROOT / 'skip_log.txt').open('a', encoding='utf-8') as f:
f.write(datetime.datetime.now().isoformat() + ' 略過檢查\n')
print('YOLO 模式:放行,並已記錄這次略過')
return 0
rules = json.loads((HERE / 'questions.json').read_text(encoding='utf-8'))
code = added_lines()
rule = next((r for r in rules if r['keyword'] in code), None)
if rule is None:
print('這次變更沒有可出題的內容,放行')
return 0
print()
print('【迷你 Sphinx】推送前請先回答:')
print(rule['question'])
for i, opt in enumerate(rule['options'], 1):
print(f' {i}. {opt}')
if ask('你的答案(輸入數字):') == str(rule['answer'] + 1):
print('答對了,放行推送')
return 0
print('不對。' + rule['explain'])
return 1
if __name__ == '__main__':
sys.exit(main())
[
{
"keyword": "seen_ids.add",
"question": "這段程式先把事件 ID 記進 seen_ids,才呼叫 charge()。如果 charge() 這次丟出錯誤,Stripe 稍後重送同一個事件,會發生什麼事?",
"options": [
"會再收款一次,客人被重複扣款",
"會直接回 200 跳過,客人永遠沒被收款",
"會自動重試 charge() 直到成功"
],
"answer": 1,
"explain": "ID 在收款之前就被記下了,所以重送時程式以為『處理過了』直接回 200,但那次收款其實失敗了,錢永遠收不到。修法:收款成功之後才記 ID,或把『記 ID』與『收款』放進同一個交易。"
}
]
def charge(customer, amount):
print(f'向 {customer} 收款 {amount} 元')
def handle_event(event):
charge(event['customer'], event['amount'])
return 200
seen_ids = set()
def charge(customer, amount):
print(f'向 {customer} 收款 {amount} 元')
def handle_event(event):
if event['id'] in seen_ids:
return 200
seen_ids.add(event['id']) # 先記下 ID,用來擋重複通知
charge(event['customer'], event['amount'])
return 200