lint 的本意是棉絮、纖維屑。名字來自 1978 年 Bell Labs 的 C 語言工具 lint,取的是「把程式裡的毛屑挑掉」這個比喻——那些不至於讓編譯失敗、但看起來不太對勁的細碎問題。
關鍵在「不至於讓編譯失敗」。看一段 Python:
def get_config(overrides={}):
overrides["loaded_at"] = time.time()
return overrides
語法完全合法,第一次呼叫也會得到預期的結果。但 Python 的 default argument 只在函式定義時求值一次,這個 dict 會在每次呼叫之間共用——第二次呼叫時,裡面還留著上一次寫進去的東西。
這是一個能跑、能通過編譯、甚至可能通過測試的 bug。
但讓 linter 抓到它的理由其實很笨:這個陷阱有固定的形狀。太多人踩過「default argument 寫成 {} 或 []」這個坑,於是它被登記成一條規則——看到 default argument 是可變的 literal,就報一次。工具並不知道這個 dict 之後會被共用,它只是在比對程式碼長什麼樣子。
linter 的本質就是這件事:把前人踩過的坑、團隊談定的約定,寫成可以自動比對的形狀。
要講清楚 linter 的價值,最快的方式是看它跟其他工具的分工:
前兩項回答的是關於這段程式的事實,第三項不是——linter 做的事是拿程式碼去比對一份規則清單。換句話說,linter 能檢查的上限,就是有沒有人把那條規則寫出來。 它不是一個更聰明的 compiler,而是一本查得很快的前人經驗表。
這也正是它跟 static analysis 的差別。linter 看的是程式碼長什麼樣子,形狀對上某條規則就報;static analysis 則會去推論程式跑起來會怎樣——這個變數在某條分支上可能是 None、這塊記憶體可能沒被釋放、這段程式碼永遠不會被執行到。前者只要掃過語法樹就能給答案,所以快到可以掛在存檔時跑;後者要沿著所有可能路徑推,通常只放在 CI。型別檢查也不歸 linter 管,那是 mypy、tsc 的工作。
這個定位跟昨天的 formatter 是同一件事的兩面。formatter 處理排版上的約定,linter 處理寫法上的約定——「不要用 except: 接所有例外」、「這個 subprocess 不要開 shell=True」這種在 code review 裡會一講再講的意見。差別在於 formatter 可以直接把答案改好,linter 多數時候只能指出位置,改法還是要人判斷。
現在的 linter 包山包海,這是有原因的。以 ruff 為例,它不是從零設計一套規則,而是把社群裡幾十個既有工具的規則——flake8、isort、pylint、bandit——全部收進同一個執行檔,每條規則保留原本出處的代號前綴。
它們被同一個工具執行,但來自不同的問題意識:
| 方向 | 在檢查什麼 | ruff 規則範例 |
|---|---|---|
| Correctness | 合法、但幾乎必然是 bug 的寫法 | B006 mutable default argument |
| Security | 可能造成安全風險的用法 | S602 subprocess 使用 shell=True |
| Performance | 有明顯更好寫法的效能問題 | PERF401 迴圈 append 可改成 comprehension |
| Maintainability | 能跑,但之後會難改 | PLR0913 參數過多、C901 分支複雜度過高 |
| Convention | 純粹的約定,跟對錯無關 | I001 import 未排序、N802 命名不符 snake_case |
這張表由上往下,確定性在遞減。B006 指出的幾乎一定是問題;但 PLR0913 說你的函式有 6 個參數,可能代表設計有問題,也可能這個函式本來就需要 6 個參數;到了 I001,就完全只是團隊選了哪一種排序而已。
所以 linter 的嚴格程度不是工具的性質,是你的選擇——規則一條一條開關,寫在專案的設定檔裡,拿pyproject.toml為例子:
[tool.ruff.lint]
select = ["E", "F", "B", "S"] # 開啟:基本錯誤、Pyflakes、bugbear、安全性
ignore = ["E501"] # 關閉:行長度(交給 formatter 處理)
常見的策略是:Correctness 與 Security 幾乎不該關,往下則依團隊的容忍度遞減。 單一檔案裡真的有例外,可以用抑制註解跳過(# noqa: B006)。這是必要的逃生門,但也是最容易被濫用的東西。
最後補一條昨天留下的線:ruff、eslint、clang-tidy 都提供 autofix,但 linter 的 autofix 不保證維持程式行為。 formatter 只動空白與換行,結果必然等價;linter 動的是程式本身。ruff 因此把 fix 分成 safe 與 unsafe 兩級,unsafe 的預設不套用——因為有些「修正」會改變語義,工具自己也知道。跑完要看 diff。
linter 給 coding agent 的東西,跟 formatter 完全相反。formatter 給的是不用看的東西,它把風格差異從 diff 裡抹掉;linter 給的則是可以讀的訊號:
src/config.py:12:20: B006 Do not use mutable data structures for argument defaults
help: Replace with `None`; initialize within function
位置、規則代號、問題描述、修改方向都在裡面。這本來是設計給人看的格式,但它剛好也是 agent 最好消化的形式——明確、可定位、每一條獨立。
把這件事跟 prompt 對照就清楚了。你可以在 AGENT.md 裡寫「不要使用 mutable default argument」,但那要靠 agent 每次生成時都記得並正確套用;linter 則是在它寫完之後,給出一個指到第 12 行第 20 個字元的確定訊號。前者是提醒,後者是驗證。
這讓 agent 有機會拿到一個它自己就能跑、自己就能讀的檢查迴圈,不需要你在中間轉述:
寫 code → 跑 linter 拿到feedback → 依 feedback 寫 code → 跑 linter 拿到feedback → ...
至於怎麼讓 agent 真的跑起這個迴圈,等 test、CI 這些工具都講完,再一次談。
有兩件事要先提醒:
選 rule set 就是在選 agent 會收到的 feedback。規則開太多,agent 每輪都被 convention 類的警告淹沒,把回合數花在排序 import 上;開太少,真正的問題它也看不到。前面那張表的順序可以直接當成優先級。
注意 agent 走捷徑。消掉一條 warning 最省事的方法不是修好它,而是加一行 # noqa。這對 agent 尤其有吸引力,因為抑制註解一定成功,修改邏輯則可能失敗。設定檔裡要明講:修問題本身;真的需要例外時,要在註解裡寫清楚理由。
最後是成本。既有專案第一次跑 linter,拿到幾百上千條 warning 很正常,而且每一條都需要判斷,沒辦法像 formatter 那樣整批接受。實務上是先只開最確定的那幾類,把數字降到可以處理的範圍再逐步往下開;有些工具也支援建立 baseline,只擋新增的部分。
到這裡,formatter 確保了風格一致,linter 指出了可疑的寫法。但這兩個工具有一個共同的限制:它們都只讀程式碼本身,不會真的執行你的程式。
get_config 那個 bug 之所以被抓到,是因為它剛好長成一個已知的、可以用規則描述的形狀。而絕大多數的邏輯錯誤沒有固定形狀——它算錯了一個數字、少處理了一個邊界條件,程式碼看起來完全正常。
這種問題只有一種方法能抓到:真的跑跑看。明天來談 test。