跳转到内容

严格类型,无处不在

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 — 一项设置,当某个忽略项目不再匹配任何项目时就使构建失败,以防止过时的抑制。
  • 设计压力 — 一种约束所产生的效果,它迫使代码以特定方式编写,而不只是像检查那样衡量代码。