Psalm 开源贡献指南:从提 Issue 到提交 Pull Request 的完整实践

原创2026-10-11 19:40:441,737 阅读
文章标签:开发工具代码质量质量保障

Psalm 开源贡献指南:从提 Issue 到提交 Pull Request 的完整实践

Psalm 是一款用 PHP 编写的 PHP 静态分析工具,用于在不执行代码的情况下发现 PHP 应用中的错误与安全漏洞。这份指南围绕仓库根目录的 CONTRIBUTING.md 及其延伸的 docs/contributing 系列文档,系统讲解贡献者如何正确提交 Issue、理解 Psalm 的两阶段分析架构、编辑 CallMap 签名库、新增 Issue 类型,并最终通过测试与 Pull Request 将改动合入上游。读完本文,你将掌握一套可直接上手的 Psalm 贡献工作流,从"使用 Psalm"进阶为"改进 Psalm"。

一、提交 Issue 的正确姿势

在 CONTRIBUTING.md 中,官方对提交 Issue 给出了两条核心建议:

  1. 确保你使用的是最新版本(master)。许多问题往往已经在开发分支上修复,先用最新代码复现可以避免重复提交;
  2. 如果是 Bug,尽量附上可以粘贴到 psalm.dev 的演示代码。能在线上沙箱中一键复现的 Bug,通常会被更快地修复。

这两条看似简单,却能显著提升 Issue 被处理的速度:可复现的最小示例是静态分析工具排错最有效的输入。

二、贡献代码与文档:先读透这三篇

正式动手之前,官方建议先阅读 docs/contributing 下三篇奠基性文档,它们分别回答三个问题:

文档 解决的问题
how_psalm_works.md Psalm 的代码库长什么样、分析是如何运转的
philosophy.md Psalm 背后为什么这样设计
what_makes_psalm_complicated.md 开发 Psalm 的难点集中在哪里

如果寻找"低垂的果实",可以在 GitHub 上筛选带有 easy problems 标签的 Issue,这些通常是不太难解决的入门任务。

心理建设:别怕改坏

一个常常被低估的事实是:Psalm 自己的代码库几乎不可能被你引入类型错误。仓库内接近 5,000 个 PHPUnit 测试构成了强大的安全网(测试入口见 tests/),即使你改错了,CI 也会第一时间发现。这一设计让新手贡献者可以大胆探索,而不必担心破坏核心功能。

三、先懂原理:Psalm 的扫描与分析两阶段架构

所有分析的入口是 ProjectAnalyzer,它只负责两件事:扫描(Scanning) 与 分析(Analysis)。理解这两阶段,是后续编辑 CallMap、新增 Issue 类型的前提。

3.1 扫描阶段:为多线程分析铺路

对于任意文件集合,Psalm 需要先确定所有可能的依赖,并拿到它们的函数签名与常量,这样分析阶段才能多线程并行。扫描发生在 Psalm\Internal\Codebase\Scanner 中。

第一步是把文件转换为 PHP Parser 的语句——PHP Parser 会把 PHP 代码变成抽象语法树(AST),Psalm 的所有分析都建立在这棵树上。

深扫描 vs 浅扫描:Psalm 使用自定义的 PHP Parser 节点访问器 ReflectorVisitor,它有两种模式:

  • 浅扫描:只提取函数签名、返回类型、常量和继承关系;
  • 深扫描:钻进每个函数语句内部,收集更深层的依赖(例如函数内部实例化的类名)。

深扫描只对"稍后会被分析"的文件执行,因此 vendor 目录中的绝大多数文件只做浅扫描。例如分析 src 目录时,下面的 A.php 会被深扫描,其父类 Vendor\VendorClass 也会被深扫描(因为可能要在某处检查属性实例化),而 Vendor\OtherVendorClass 及其依赖只需要浅扫描——分析 $c 时关心的只是方法签名与返回类型:

<?php
use Vendor\VendorClass;
use Vendor\OtherVendorClass;

class A extends VendorClass
{
    public function foo(OtherVendorClass $c): void {}
}

类名到文件的映射:项目文件用反射(reflection)解析,vendor 文件则依赖 Composer 的 classmap。

扫描数据的存储:ReflectorVisitor 每访问一个文件,Psalm 就创建对应的 FileStorage 实例,并根据文件内容创建 ClassLikeStorage 与 FunctionLikeStorage。拿到全部文件、类与函数签名后,由 Populator 计算所有继承关系,然后进入分析阶段。

扫描结束时,所有必要信息被填充进 ClassLikes、Functions、Methods 等类,并分别在 FileStorageProvider 与 ClassLikeStorageProvider 中形成完整的 FileStorage 与 ClassLikeStorage 对象清单。

3.2 分析阶段:逐行类型推导

文件分析在 FileAnalyzer 中进行:它找出文件中的顶层组件(类、trait、接口、函数),也能钻进命名空间提取其中的类、接口、trait 与函数,然后把分析委托给 ClassAnalyzer、InterfaceAnalyzer 与 FunctionAnalyzer。

以最基础、无需考虑类继承的 FunctionAnalyzer 为例:

  1. FunctionAnalyzer::analyze 定义在 FunctionLikeAnalyzer 中,它先取出扫描阶段为函数创建的 FunctionLikeStorage;
  2. 把函数参数信息喂给 Context 对象——Context 保存了所有变量与属性的类型信息(存储在 Context::$vars_in_scope),以及会随赋值与断言变化的其他信息;
  3. 创建 StatementsAnalyzer 并调用其 analyze(),把 PhpParser 节点交给 IfAnalyzer、ForeachAnalyzer、ExpressionAnalyzer 等一系列更细粒度的检查器;
  4. 在分支点(if、循环、三元等)Context 会被克隆,分支结束时 Psalm 计算如何合并变更并更新未被克隆的 Context;
  5. 每个 PhpParser 节点的类型由 NodeDataProvider 保存;全部语句分析完毕后,汇总所有返回类型并与声明返回类型比对。

类型调和(Type Reconciliation):有些 Context 更新并不那么直白,需要在 Reconciler 中完成——它接收一组断言(如 ["$a" => "!null"])和现有类型信息(如 $a => string|null),返回更新后的信息(如 $a => string)。这正是 Psalm 在 if 条件、三元表达式等场景中收窄类型的核心机制。

四、第一个常见贡献点:编辑 CallMap

绝大多数贡献者的第一个改动是修改 CallMap——这也是官方指南 editing_callmaps.md 的开篇内容。CallMap 是一份以 PHP 文件形式返回数组的数据文件,告诉 Psalm 某个函数/方法接受什么参数、返回什么类型。

4.1 文件组织与再生成流程

CallMap 相关文件分布在三个位置:

  • dictionaries/override/CallMap.php:手工维护的部分 CallMap;
  • dictionaries/override/CallMap_*_delta.php:记录各 PHP 版本中签名如何变化的 delta 文件;
  • dictionaries/autogen/:由反射自动生成的 CallMap(对应各 PHP 版本,如 CallMap_85.php);
  • dictionaries/CallMap_*:由 override 与 autogen 联合生成的最终生效 CallMap。

修改某个函数的信息时,在 dictionaries/override/CallMap* 中编辑签名(函数缺失时从 dictionaries/autogen/CallMap_$latest.php 复制过来),并且不要忘记同步修改 dictionaries/override/CallMap_*_delta.php 中同一函数的各个历史版本。改完后运行 bin/stubs/gen_callmap.php(对应文档中描述的 bin/gen_callmap.php,当前仓库中脚本位于 bin/stubs/ 目录)重新生成最终 CallMap;若要连基础 CallMap 一起重新生成,则运行 bin/stubs/gen_callmap.sh——它会利用 bin/ 下的 Dockerfile 从 PHP 及一系列扩展中提取类型。要为某个扩展添加类型,需要编辑所有版本的 Dockerfile。

值得注意的是,composer.json 中也预置了 composer gen_callmap 脚本(指向 ./bin/stubs/gen_callmap.sh),方便一键再生成。

4.2 完整 CallMap 格式

完整 CallMap(dictionaries/override/CallMap.php)以函数/方法名为键,值为表示签名的数组:

  • 数组第一个元素(无键)是返回类型;
  • 后续元素是参数;
  • 可选参数的参数名以 = 结尾;
  • 引用参数以 &/&r_/&w_/&rw_ 前缀(根据引用的读写含义区分);
  • **可变参数(variadic)**以 ... 前缀。

4.3 函数别名:同一函数多份签名

CallMap 支持函数别名——这在"同一函数根据参数类型表现不同"时非常有用。例如 version_compare 在传入 operator 参数时返回 bool,否则返回 int,于是 CallMap 中同时存在两条记录:

[
    'version_compare' => [
        0 => 'bool',
        'version1' => 'string',
        'version2' => 'string',
        'operator' => '\'!=\'|\'<\'|\'<=\'|\'<>\'|\'=\'|\'==\'|\'>\'|\'>=\'|\'eq\'|\'ge\'|\'gt\'|\'le\'|\'lt\'|\'ne\'|null',
    ],
    'version_compare\'1' => [
        0 => 'int',
        'version1' => 'string',
        'version2' => 'string',
    ],
]

文档特别提醒:上面的示例对类型推断几乎没有提供有用信息,version_compare 真正的返回类型推断逻辑位于 VersionCompareReturnTypeProvider 中。CallMap 的主要价值在于把函数/方法**当作可调用值(callable)**处理时,例如:

<?php
function naive_version_compare(string $a, string $b, ?string $operator = null): int|bool {
    return 0;
}

$a = ["1.0", "2.0"];

// OK
usort($a, "version_compare");

// InvalidArgument: Argument 2 of usort expects callable(string, string):int, but impure-callable(string, string, null|string=):(bool|int) provided
usort($a, "naive_version_compare");

第一个 usort 成功,因为 Psalm 从 CallMap 的两条别名中选对了那条返回 int 的签名;第二个 usort 失败,因为 naive_version_compare 的返回类型是两个可能签名的联合。对于这类"多面手"函数,文档建议至少在 stubs/ 中定义一个模板化 stub;更复杂的逻辑(无法用模板/条件类型表达)则建议编写自定义返回类型 Provider。

另外,bin/stubs/gen_callmap.php 带有校验逻辑,会把被覆盖的别名 CallMap 中已删除的参数重新加回来;要避免这一点,需显式地在 bin/stubs/gen_callmap_utils.php 的 assertParameter 中白名单化这些别名函数。

4.4 Delta 文件格式与反向应用机制

Delta 文件(命名为 CallMap_<主版本><次版本>_delta.php,例如 dictionaries/override/CallMap_81_delta.php)记录对应 PHP 版本发生的签名变化,包含三个区块:

  • added:该 PHP 版本新增的函数/方法;
  • removed:该版本移除的函数/方法;
  • changed:签名发生变化的条目,每条列出 old 与 new 两个签名。

removed 与 added 的条目格式与完整 CallMap 一致;changed 条目则同时列出新旧签名。

当配置的 PHP 版本不是 Psalm 支持的最新版本时,Psalm 按如下流程倒推得到匹配的 CallMap:

  1. 读取 CallMap.php(注意:它持有的是最新签名);
  2. 若与配置的 PHP 版本匹配,直接使用;
  3. 否则,若存在前一个 PHP 版本的 callmap delta,读取之;
  4. 反向应用该 delta:removed 区块的条目被加回,added 区块的条目被删除,当前 CallMap 中的 changed.new 签名被替换为 changed.old;
  5. 回到第 2 步继续。

CI 还会校验所有函数/方法条目拥有一致的历史(consistent histories),例如 changed.new 中的签名必须与 CallMap.php 一致、removed 条目确实不在 CallMap.php 中等。

4.5 两类典型改动

  • 新增函数:例如 PHP 8.1 新增的 array_is_list(),需要同时写入 CallMap_81_delta.php(因为它诞生于 PHP 8.1)与 CallMap.php(因为它存在于最新 PHP 版本中);
  • 修正签名:若签名从一开始就是错的,只需修改 CallMap.php;若签名在某个历史版本是正确的、之后才发生变化,则需修改对应 PHP 版本的 delta 文件与 CallMap.php(例如把 timestamp 参数改为可空的那类改动)。

五、添加一个全新的 Issue 类型

如果你想让 Psalm 报告一种前所未有的错误,官方指南 adding_issues.md 给出了完整的七步流程。

5.1 生成新的短码(Shortcode)

运行 bin/docs/max_used_shortcode.php(文档写作 bin/max_used_shortcode.php,当前仓库中脚本实际位于 bin/docs/ 目录),记下它输出的值 $max_shortcode。该脚本通过 IssueHandler::getAllIssueTypes() 枚举全部 Issue 类型,取其 SHORTCODE 常量的最大值。

5.2 创建 Issue 类

在 Psalm\Issue 命名空间下创建类,SHORTCODE 使用 $max_shortcode + 1:

<?php

namespace Psalm\Issue;

final class MyNewIssue extends CodeIssue
{
    public const ERROR_LEVEL = 2;
    public const SHORTCODE = 123;
}

错误等级的选择参考 error_levels.md(Level 1 最严格,Level 8 最宽松,未指定时默认 Level 2)。

除基类 CodeIssue 外,还有若干抽象基类可选用,它们决定了该 Issue 能否在 psalm.xml 中被定向压制(suppress):

抽象基类 适用对象 psalm.xml 定向属性
CodeIssue 通用 Issue,所有 Issue 的基类 —
ClassIssue 类/接口/trait/枚举相关 referencedClass
PropertyIssue 属性相关 referencedProperty
FunctionIssue 函数相关 referencedFunction
ArgumentIssue 参数相关 referencedFunction
MethodIssue 方法相关 referencedMethod
ClassConstantIssue 类常量相关 referencedConstant
VariableIssue 变量相关 referencedVariable

以 ClassIssue 为例,其构造函数额外接收 fq_classlike_name,正是该字段支撑了按类定向压制的能力。

5.3 补充配置与文档

  • config.xsd 条目:所有 Issue 类型必须列在 config.xsd 中,它用于校验 psalm.xml。选择正确的 type 属性,例如继承 PropertyIssue 的 Issue 使用 type="PropertyIssueHandlerType";
  • 文档页面:在 docs/running_psalm/issues 文件夹中为每个 Issue 创建 markdown 文档。代码片段必须是 fenced 的 php 代码块,且必须包含开头的 <?php 标签,并且该片段必须真实触发你正在文档化的 Issue——它会被测试套件检查;
  • 添加链接:把新页面链接加入 docs/running_psalm/error_levels.md 与 docs/running_psalm/issues.md。

5.4 运行文档测试

$ vendor/bin/phpunit tests/DocumentationTest.php

DocumentationTest.php 会核查上述步骤是否完成(例如 testAllIssuesCovered 会比对全部 Issue 类型与文档代码块的一致性)。

5.5 在 Psalm 核心中使用

IssueBuffer::maybeAdd(new MyNewIssue(...))

通过 IssueBuffer 把新 Issue 注入分析流程即可。

六、Pull Request 与测试规范

提交 Pull Request 前必须遵循两条铁律(见 docs/contributing/index.md):

  1. 本地跑通集成检查:composer tests;
  2. 新增功能或修复 Bug,务必补测试。

在 composer.json 中可以看到 composer tests 实际串联了四道检查:

脚本 作用
lint 用 parallel-lint 对 src、tests 做语法检查
cs 用 phpcs 检查代码风格
psalm 用 Psalm 自身对代码库做静态分析(自举)
phpunit 用 paratest 并行运行全部 PHPUnit 测试

这一设计也印证了文档的说法:接近 5,000 个 PHPUnit 测试(见 tests/ 目录)加上多层 CI 校验,让贡献者可以放心改动。此外仓库还预置了 verify-callmap 脚本(运行 tests/Internal/Codebase/InternalCallMapHandlerTest.php),专门校验 CallMap 与 delta 的一致性。

七、理解 Psalm 的设计哲学与开发难点

7.1 设计哲学要点

philosophy.md 明确了 Psalm 的几个关键取舍:

  • 用 PHP 写 PHP 工具:保证 PHP 社区成员能无障碍参与贡献;代价是超大 monorepo(超过 500 万行受检代码)中速度偏慢,语言服务器的能力也比编译型语言工具受限;
  • 核心目标是找 Bug:语言服务器、安全分析、代码修复等一切功能都是次要目标;
  • 只运行在语法正确的代码上:Psalm 几乎总是分析通过 php -l 的代码,因此使用 nikic/php-parser 作为金标准解析器;
  • Docblock 注解优先于类型提供型插件:可验证的注解(如条件类型 conditional_types)比不可验证的插件更有价值;
  • 部分注解暂无法验证:如 @psalm-assert、@property、@method、@mixin 等,新增注解时应努力让 Psalm 能够验证它们。

7.2 开发难点清单

what_makes_psalm_complicated.md 则列出新手可能遇到的复杂度区域:

  • 类型推断:循环(break/continue)、类型合并、逻辑断言、泛型与模板代码(@template,含嵌套模板 @template T1 of T2);
  • 死代码检测:未使用变量的检测依赖数据流分析;跨多次运行检测未使用的类与方法需要维护缓存中的引用;
  • 社区支持:正式与非正式 PHPDoc 注解(如 ArrayIterator|string[])、非 Composer 项目(如 WordPress);
  • 性能:基于解析器的反射、进程 fork(非 Windows,借鉴自 Phan)、缓存;
  • 缓存失效:跟踪方法/属性被哪些文件使用、部分重解析变更文件;
  • 语言服务器:处理临时文件变更、容忍用户编写中的畸形 PHP 代码;
  • Psalter 代码修复:生成"像人写的一样"的最小 diff。

理解这些难点,能帮助你在贡献时快速定位问题属于哪个子系统,从而查阅对应源码。

结语

从提交可复现的 Issue,到读懂 ProjectAnalyzer 的扫描/分析两阶段架构,再到动手编辑 CallMap、新增 Issue 类型,最后通过 composer tests 与文档测试提交 Pull Request——这就是 Psalm 贡献者的完整进阶路径。得益于接近 5,000 个 PHPUnit 测试与多层 CI 校验,你完全可以放心地边读源码边改代码。下一步,不妨从 docs/contributing 中推荐的 easy problems 标签 Issue 开始你的第一次贡献。

登录后查看全文
psalm