跳到內容

嚴格型別,無所不在

Spec: ISO 32000-2, §7.5.5 Evidence: Code-backed PHPStan: Level 10, no src baseline

NextPDF 在引擎原始碼上以 Level 10 執行 PHPStan,且不使用任何抑制基準(baseline)。本頁說明「不使用基準」為何是一項設計決策,而非工具細節;也說明對一條唯一職責就是絕不誤處理資料的管線而言,這種嚴格究竟換來什麼。

在大多數應用程式中,嚴格型別只是一種衛生習慣。放在 PDF 引擎裡,它更接近一種正確性機制。這個格式毫不寬容。讀取器預期從檔案的末端開始,透過 trailer 與交叉參照表(cross-reference table)來定位內容,因此寫入器的位元組偏移量必須精確無誤。想像一個悄悄放寬為 mixed 的型別、一個 int 無聲變成 string,或是一個可為 null 的值未經檢查就被解參考。這些情況中的任何一種,都可能產生一個在某個檢視器中能正常開啟、卻在數週後於另一個檢視器驗證失敗的檔案,而且沒有任何堆疊追蹤指回根本原因。

在這個領域裡,代價最高的失敗往往正是無聲無息的失敗。嚴格型別搭配嚴格的分析器,正是引擎將一整類無聲的執行階段失敗,轉換成會在建置階段明確失敗的錯誤的方式。

  • 引擎原始碼以 PHPStan Level 10(最嚴格的層級)進行分析,並在 phpstan.neon.dist 中驗證。
  • 沒有使用原始碼抑制基準。該設定將原始碼分析鎖定在零錯誤。回歸(regression)會使建置失敗,而非被吸收進一個不斷膨脹的忽略檔案中。
  • 現存的少數 ignoreErrors 項目是範圍狹窄、受識別碼與路徑限定,且在設定中逐一說明理由的(跨套件軟相依邊界,以及反射目標的測試接縫)——而非一份批量基準。
  • 另有一份獨立的嚴格設定檔level: max 執行,並禁止新增任何忽略項目,因此新程式碼被要求遵守更嚴苛的標準。
  • 預期效果是一種設計壓力:無法以型別誠實方式表達的程式碼無法通過,因此它會被重新設計,而非被抑制。

「我們使用一個嚴格的分析器」與「我們使用一個不帶基準的嚴格分析器」之間的差別,正是整件事的關鍵,因此值得把話說清楚。

基準會記錄每一項現存的違規,並告訴分析器只忽略那些項目。在既有的舊程式碼基底上導入靜態分析時,這是一種務實的做法,但它有代價。基準會變成一份無聲的債務帳本,記下型別系統已同意不再檢視的部分。同一類型的新違規,可能會緊挨著那些被既往不咎的違規一同溜進來。分析器的承諾,從「這份程式碼在型別上是乾淨的」弱化為「這份程式碼不比原來更糟。」

對引擎原始碼而言,NextPDF 並不接受這樣的取捨。該設定將原始碼分析固定在零錯誤,並開啟 reportUnmatchedIgnoredErrors,因此即使是一項過時的抑制——已不再符合任何項目的抑制——也會使建置失敗。所保留的狹窄忽略項,皆限定於特定的錯誤識別碼與檔案。每一項都附帶行內說明,闡明為何該邊界是刻意為之(例如,core 針對某個它刻意不具體相依的 Pro/Enterprise 介面進行程式設計)。審查者可以逐一閱讀並加以判斷。沒有任何令人失去掌握的不透明清單。

維持這份誠實的流程如下:

  1. Change proposed New or modified engine code.
  2. Level 10 analysis Strictest PHPStan level over src/, treatPhpDocTypesAsCertain on.
  3. Zero-error gate No source baseline; unmatched ignores also fail.
  4. Strict profile level: max; no new ignore entries permitted.
  5. Redesign, not suppress If it cannot be expressed honestly, the design changes.
一項變更如何抵達引擎原始碼:型別不誠實的變更無法通過閘門,因此會被重新設計,而非被抑制。

treatPhpDocTypesAsCertain 是其中的一環。PHPDoc 註解被視為基準事實,因此 @param list<T>@return non-empty-string 並不是分析器可以客氣略過的註解。它是一份經過檢查的承諾。註解與執行階段型別會被強制保持一致。

本頁屬於 Evidence: Code-backed 。設定本身就是證據:

  • phpstan.neon.dist 設定了 level: 10phpVersion: 80400,分析 src,且不含 baseline:——原始碼分析並沒有 phpstan-baseline.neon
  • 同一份檔案設定了 treatPhpDocTypesAsCertain: truereportUnmatchedIgnoredErrors: true,並附上行內註解,說明 L10 原始碼分析鎖定在零錯誤,任何回歸都必須使 CI 失敗。
  • 其餘的 ignoreErrors 皆以 identifier、且通常也以 path 為範圍,並附有註解說明軟相依與反射目標的理由——它們並非批量產生的基準。
  • phpstan-strict.neon.dist 繼承該設定,將層級提升至 max,並凍結忽略清單,因此在嚴格設定檔下不得新增任何項目

從標準的角度看,這一點很直接。依據 Spec: ISO 32000-2, §7.5.5 ,引擎必須產生讀取器能從 trailer 與交叉參照表正確走訪的檔案。精確的位元組偏移量, 在成為序列化問題之前,先是一個型別問題。偏移量是一個絕不可無聲變成其他任何東西的整數。一條在 Level 10 達到型別乾淨的管線,已經消除了算術可能悄悄出錯的大多數途徑。

當一條領域規則被編碼為型別,而非執行階段檢查時,嚴格型別的價值最為清楚。符合性判別器以窮盡的 match 回答規範層級的問題,因此未處理的情況會成為型別錯誤,而不是錯誤的 PDF:

declare(strict_types=1);
enum ConformanceMode: string
{
case Plain = 'plain';
case PdfUa2 = 'pdfua2';
case PdfA4 = 'pdfa4';
/** @return 2|3|4|null */
public function pdfaPart(): ?int
{
return match ($this) {
self::PdfA4 => 4,
default => null,
};
}
}

這個 @return 2|3|4|null 不是單純的文件說明。在 treatPhpDocTypesAsCertain 之下,它會接受檢查。若呼叫端假設結果永遠是 int,分析階段就會指出問題,早於任何不符規範的 PDF/A 部分編號被寫成位元組之前。

陷阱在於把「不使用基準」解讀為「程式碼剛好沒有任何違規」。這正好說反了。沒有基準是原因,而非幸運的結果。因為沒有地方可以擱置違規,會產生違規的程式碼就必須換一種寫法。不帶原始碼基準的 Level 10 是一項形塑設計的約束,而非事後描述設計的成績單。

第二種誤解:認為那少數幾項 ignoreErrors 項目只是換個名字的基準。它們並非如此。基準是批量產生且不透明的。這些則是逐一撰寫、以識別碼為範圍、附有說明,並由 reportUnmatchedIgnoredErrors 把關,因此不會在無人察覺下腐化。

本頁談的是引擎原始碼分析。測試套件是在一個獨立、刻意有別的範圍與設定下進行分析;此處的「不使用基準」是針對 src/ 的陳述,並非主張儲存庫中每一項輔助分析都沒有基準。PHPStan 證明的是型別健全性,而非行為正確性。它不會取代測試金字塔,只是移除了一類否則就得由測試追查的失敗。確切的層級、旗標與忽略集,皆以本頁的審閱日期為準。權威來源永遠是 core 儲存庫中的 phpstan.neon.distphpstan-strict.neon.dist

版本不會改變這項紀律。每一個版本都建構自同一份 Level 10 原始碼:

Level 10 source analysis — edition availability
Edition Availability
Core Core 原始碼以 Level 10 進行分析,且不帶原始碼基準。
Pro Pro 建構於同一套 Level 10 原始碼紀律之上。
Enterprise Enterprise 建構於同一套 Level 10 原始碼紀律之上。
  • PHPStan Level 10 — 最嚴格的分析層級,將未具型別與鬆散型別的值視為錯誤,而非警告。
  • 基準(Baseline) — 一份由工具產生的現存違規記錄,用來告知分析器忽略這些項目。NextPDF 在引擎原始碼上完全不使用基準。
  • treatPhpDocTypesAsCertain — 一項 PHPStan 設定,將 PHPDoc 型別註解視為經過檢查的事實,而非僅供參考的註解。
  • reportUnmatchedIgnoredErrors — 一項設定,一旦某個忽略項目不再符合任何項目,就使建置失敗,以防止過時的抑制。
  • 設計壓力 — 一種約束所產生的效果,它迫使程式碼以特定方式撰寫,而不只是像檢查那樣衡量它。