Psalm 的 ConstantDeclarationInTrait:为什么 PHP 8.2 之前 Trait 里不能声明常量

原创2026-10-09 14:38:521,657 阅读
文章标签:开发工具代码质量质量保障

Psalm 的 ConstantDeclarationInTrait:为什么 PHP 8.2 之前 Trait 里不能声明常量

本文聚焦 Psalm 的问题类型 ConstantDeclarationInTrait:它何时被触发、底层由哪个扫描器基于哪个 PHP 版本判断做出这一判定,以及如何通过 psalm.xml 中的 phpVersion 配置控制它的出现。读完后你将理解 Psalm 在“解析/扫描阶段”而非“分析阶段”提前拦截 Trait 常量声明的原理,并掌握在不同目标 PHP 版本下正确配置静态分析的方法。

问题定义:Trait 中声明常量的致命错误

ConstantDeclarationInTrait 在 PHP < 8.2.0 下、一个 trait 声明了常量时被 Psalm 报告。原文档给出的最小复现代码是:

<?php

trait A {
    const B = 0;
}

触发该问题的后果是抛出致命错误(fatal error)。PHP 语言规范直到 8.2 才允许在 trait 中声明类常量(见 RFC "Class constants of enums" 之前的配套变更中引入的 trait constants),因此在 8.2 之前的任何 PHP 运行时中,上述代码在编译期即失败,根本运行不到任何业务逻辑。

ConstantDeclarationInTrait 在 issues 总览 和 error_levels 文档 的列表中都列于最前,可见其属于“代码无法运行”级别的最高优先级问题。

实现机制:扫描器在 Trait 存储上提前拦截

该问题对应的 Issue 类是 src/Psalm/Issue/ConstantDeclarationInTrait.php,结构非常简洁:

final class ConstantDeclarationInTrait extends CodeIssue
{
    public const ERROR_LEVEL = -1;
    public const SHORTCODE = 315;
}
  • ERROR_LEVEL = -1:表示这是一个“致命”级别问题,不属于普通的错误/警告/信息分级体系(-1 高于 error 级别),意味着它在默认配置下无法被降级忽略掉——因为代码本身就无法通过 PHP 编译;
  • SHORTCODE = 315:是 Psalm 报告(尤其 JSON/SARIF 等机器可读输出)中该问题类型对应的数字编码。

判定逻辑位于类/接口/Trait 扫描阶段。从 ClassLikeNodeScanner::visitClassConstDeclaration() 的源码可以看到核心条件:

private function visitClassConstDeclaration(
    PhpParser\Node\Stmt\ClassConst $stmt,
    ClassLikeStorage $storage,
    string $fq_classlike_name,
): void {
    if ($storage->is_trait && $this->codebase->analysis_php_version_id < 8_02_00) {
        IssueBuffer::maybeAdd(new ConstantDeclarationInTrait(
            'Traits cannot declare constants until PHP 8.2.0',
            new CodeLocation($this->file_scanner, $stmt),
        ));
        return;
    }
    // ... 后续才是正常的常量类型解析、@var docblock 提取等逻辑
}

两个关键判据缺一不可:

  1. $storage->is_trait——当前被扫描的类样体(classlike)必须是 trait;
  2. $this->codebase->analysis_php_version_id < 8_02_00——目标分析 PHP 版本(注意不是 Psalm 自身运行时的 PHP 版本)低于 8.2.0,版本 ID 采用 主版本 × 10000 + 次版本 × 100 的编码方式,因此 8_02_00 即 8.2.0。

注意这里的细节:一旦命中条件,maybeAdd 之后立即 return,不再继续执行常量类型解析。这意味着在目标版本 < 8.2 时,该 trait 常量不会进入 Psalm 的类型模型——反正这段代码在运行时根本无法存活。

报告链路:Issue 如何到达最终输出

ConstantDeclarationInTrait 继承自 CodeIssue。CodeIssue::toIssueData() 会把 SHORTCODE(315)、ERROR_LEVEL(-1)、文件路径、行号、列号与选中文本(即 const B = 0; 这一行)打包进 IssueData,再经由 IssueBuffer::maybeAdd() 进入统一的问题缓冲区,最终随报告器(控制台、JSON、SARIF 等)输出。也就是说,你在报告中看到的问题名、短码、代码位置都来自这条链路,且位置精确定位到触发问题的 ClassConst 语句节点上。

analysis_php_version_id 从哪来:决定该问题是否出现的版本判定

源码里的判断依赖 $codebase->analysis_php_version_id,它在 Codebase.php 中初始化为当前运行时 PHP_VERSION_ID,之后可以被显式指定覆盖。在 ProjectAnalyzer.php 中可以看到解析流程:

[$php_major_version, $php_minor_version] = explode('.', $version);
$analysis_php_version_id = $php_major_version * 10_000 + $php_minor_version * 100;

if ($this->codebase->analysis_php_version_id !== $analysis_php_version_id) {
    // reset parser when php version changes
    StatementsProvider::clearParser();
}

$this->codebase->analysis_php_version_id = $analysis_php_version_id;
$this->codebase->php_version_source = $source;

可以推断版本来源(php_version_source)按优先级大致为:命令行 --php-version 参数 > 项目 composer.json 中的平台 php 约束 > psalm.xml 的 <phpVersion> 配置 > 运行时 PHP 版本。其中 psalm.xml 配置在 Config.php 中被读取:

if (isset($config_xml['phpVersion'])) {
    $config->configured_php_version = (string) $config_xml['phpVersion'];
}

且 config.xsd 对版本号格式有约束(config.xsd 中的 phpVersion 元素)。

这一点对实操非常关键:即使你本机运行 PHP 8.3(trait 常量完全合法),只要项目声明的目标版本是 8.1,Psalm 仍会报告 ConstantDeclarationInTrait。反之,如果目标版本 ≥ 8.2,同一份代码就不会触发该问题。这也意味着该问题是一个“环境敏感型”问题:同一份代码在不同 phpVersion 配置下报告结果不同。

测试验证:PHP 8.1 下精确复现

tests/TraitTest.php 中包含了对应的回归测试用例:

'constant declaration in trait, php <8.2.0' => [
    'code' => <<<'PHP'
        <?php
        trait A { const B = 0; }
        PHP,
    'error_message' => 'ConstantDeclarationInTrait',
    'ignored_issues' => [],
    'php_version' => '8.1',
],

测试显式将 php_version 设为 8.1,验证 trait A { const B = 0; } 精确触发 ConstantDeclarationInTrait。注意用例名特意写成 "php <8.2.0",与文档描述、源码条件一一对应;而 TraitTest 中其他用例(如 duplicateTraitProperty 触发 DuplicateProperty)不带版本限定,说明常量重复等冲突检测在 8.2+ 场景下照常工作,本问题只针对“trait + 旧版本”的组合。

版本相关的连锁影响:不只是少报一个问题

analysis_php_version_id 还驱动 Psalm 加载对应的 PHP 核心 stub。在 Config::visitPreloadedStubFiles() 中可以看到成对的版本判断:

if (PHP_VERSION_ID < 8_02_00 && $codebase->analysis_php_version_id >= 8_02_00) {
    $stringable_path = dirname(__DIR__, 2) . '/stubs/Php82.phpstub';
    // ...
    $core_generic_files[] = $stringable_path;
}

含义是:当“目标版本 ≥ 8.2”但“运行 Psalm 的 PHP < 8.2”时,Psalm 会预加载 stubs/Php82.phpstub 等版本化 stub 文件,用静态声明补足本机反射得不到的新类与新特性。可以推断同一机制下,< 8.2 的目标版本则不会加载这些新特性声明,从而保持类型模型与目标运行时一致——trait 常量检查正是这套“按版本裁剪语言特性”策略中的一环。

如何处置该问题

由于这是编译期致命错误,处置方式只有两条路径:

  1. 修改代码(首选):将常量从 trait 中移出。常见做法是把常量声明到使用它的各个类中,或抽取到一个基类/接口常量容器(interface 常量在旧版本中可用)中,trait 内通过 use 继承的常量来引用;
  2. 确认目标版本(如属误报):如果项目确实运行在 PHP 8.2+ 且 trait 常量是有意为之,检查 psalm.xml 的 <phpVersion> 与 composer.json 的 php 平台约束是否被正确声明为目标 8.2+,使 analysis_php_version_id ≥ 8.2.0,该问题即不再报告。

在报告层面,ConstantDeclarationInTrait 已在 config.xsd 的 Issue 元素白名单中注册(<xs:element name="ConstantDeclarationInTrait" type="IssueHandlerType" minOccurs="0" />),因此你可以在 psalm.xml 中为它配置 <issueHandlers> 级别的抑制策略;但鉴于其 ERROR_LEVEL = -1 的致命定位,抑制它通常只会掩盖真正的运行故障,建议仅用于过渡期基线管理。

小结

登录后查看全文
psalm