首页
/ CodeGraph PHP 内核移植实战:从 wasm 提取器到 Rust 原生 Walkers 的 bug-for-bug 行为一致性

CodeGraph PHP 内核移植实战:从 wasm 提取器到 Rust 原生 Walkers 的 bug-for-bug 行为一致性

2026-09-06 15:33:28作者:范靓好Udolf

本文基于 CodeGraph 仓库中的移植设计文档 php-kernel-port-checklist.md(状态:PORT COMPLETE,2026-07-20)展开。它将回答一个非常具体的工程问题:当 CodeGraph 把 PHP 文件的符号提取从 TypeScript + WASM 版 tree-sitter 迁移到 Rust 原生内核时,如何做到"逐 bug 复刻"——即新实现不仅在正确场景下与旧实现一致,连旧实现里那些"看起来不合理"的输出形状(垃圾引用的精确文本、多继承被丢弃的后 N-1 个基类、命名空间文件的顶层常量被 value-ref 丢弃)都必须字节级保留。读完后,你可以掌握一套可复用的"解释器双实现一致性迁移"方法论:先升级语法(grammar bump)并枚举分类其行为差异,再逐分支复刻提取器钩子,最后用全仓库 parity 扫描 + 全量初始化 dump 字节比对作为门禁。

1. 背景:PHP 进入内核路由前的三件事

CodeGraph 的提取管线有一条 TS 侧的 WASM 提取路径(tree-sitter 编译为 wasm),以及一条 Rust 内核路径(codegraph-kernel 编译为原生二进制,每文件一次 JS 边界穿越)。TS 侧维护一个"默认路由到内核"的语言集合 DEFAULT_ROUTED——注释明确写着"gate-passed only",且每个文件有一个与路由无关的安全阀:parse tree 含错误的文件一律 defer 给 wasm 提取器(UTF-8 与 UTF-16 两种解析路径的错误恢复行为不同,wasm 的恢复是规范路径)。

PHP 加入这个集合的前提,按文档 §Gates 要求,是三件事按序落地:

  1. 语法 bump 先行(在任何 Rust walker 存在之前):把生产加载的 2023 年 ABI-14 版 wasm 替换为 v0.24.2,并枚举分类新旧 dump 的全部差异;
  2. Parity 门禁:torture fixture 套件 + 三个真实仓库的全量扫描 0 差异 + full-init dump 字节一致 ×3;
  3. 最后才把 php 加入 DEFAULT_ROUTED,注释中记录证据:"parity swept 0-diff on monolog/laravel-framework/symfony (13,950 files byte-parity) + full-init dump-diffs byte-identical ×3. Deferral ≈0–0.1%"。

仓库中的实现事实与文档一一对应:langs.rsLANGUAGES 常量已含 "php"(共 20 种语言),grammar_for"php" 返回 tree_sitter_php::LANGUAGE_PHPlangs.rs#L65-L67);Cargo.toml#L41-L43tree-sitter-php = "=0.24.2" 精确 pin 住版本,注释重申"NEVER LANGUAGE_PHP_ONLY"。

2. 语法准备:选对变体、选对版本、只用 check-in 的 parser.c

这是整份 checklist 的第一步,也是最容易踩坑的一步。文档给出三个约束:

变体:完整的 php 语法,而不是 php_only tree-sitter-php 仓库是双语法结构,crate 同时导出 LANGUAGE_PHPLANGUAGE_PHP_ONLY。调研阶段通过探测确认:当前生产 wasm 能解析混合 HTML+PHP 文件,根节点出现 text / php_tag / text_interpolation 节点且无错误——这正是 php/ 变体的行为。php_only 变体对任何前置 HTML 直接 ERROR,而"PHP 文件带 HTML 头"是 Drupal/遗留系统的常态。这条结论在 Cargo.toml#L41-L43langs.rs#L65-L67 的注释里都做了固化。

版本:crate tree-sitter-php 0.24.2(crates.io max_stable),对应仓库 tag v0.24.2、commit 5b5627faaa290d89eb3d01b9bf47c3bb9e797dea。tag 与 crate tarball 做了 sha256 匹配:

  • php/src/parser.c 59ad8e5e…d27ec6
  • php/src/scanner.c 58c92caf…291ad5 —— 这是一个薄封装:真正的外部扫描器是共享的 common/scanner.h(heredoc/nowdoc、encapsed 字符串、?>/text 交错都在那里),crate 构建时自动编译它。

构建:从 tag 的 check-in parser.c 构建,永不 tree-sitter generate 文档给出的构建序列(去除仓库地址后)为:

# 克隆 tree-sitter/tree-sitter-php 的 v0.24.2 tag(--depth 1)
cd tree-sitter-php/php        # 变体子目录 —— 不是仓库根
npx tree-sitter-cli@0.25.10 build --wasm -o tree-sitter-php.wasm .

产出物:ABI 15、1,058,082 字节、sha256 6545a9a1…c82 的 wasm。

落地清单(仓库中均已可见):vendor 到 src/extraction/wasm/tree-sitter-php.wasm;把 'php' 加入 VENDORED_WASM_LANGS——grammars.ts#L309-L315 的注释完整记录了 R7b 决策("NOT graph-neutral — the classified delta list lives in the php checklist doc"),resolveWasmPath 因此走本地目录而非 tree-sitter-wasms npm 包;copy-assets 已 glob src/extraction/wasm/*.wasm,无需改动。

错误率探测:升级是否值得

文档在升级前先对三个门禁仓库的全部 php 路由文件集(1 MiB 跳过规则生效)做了新旧 wasm 的错误率对比:

仓库 文件数 旧版 (ABI-14 ^0.22) 新版 (v0.24.2)
monolog 217 1(0.46%)— Level.php(enum const) 0(0.00%)
laravel/framework 2,999 3(0.10%) 1(0.03%) — 故意损坏的测试 fixture
symfony 10,736 40(0.37%) 11(0.10%) — 损坏的/8.4+ fixture

两侧都落在 ts/java/py/go 的正常区间(0–0.42%)内。因此 deferral 守护保持默认 --max-deferral 0.1——c/cpp 的 0.5 豁免不适用:php 扫描中出现两位数 deferral 就是 walker 损坏的信号。旧语法独有的失败(被 bump 修复,逐构造探测确认):enum 体内的 const、property hooks(8.4)、asymmetric visibility(8.4)。其余 8.0–8.3 特性(enums、readonly、promotion、DNF/intersection 类型、first-class callables、nullsafe、match、attributes、named args、typed class consts)在两个语法上都解析干净。

3. 语法 bump 的差异分类:为什么 PHP 的 bump 门禁不是"期望零 diff"

与 rust 移植不同,PHP 的 bump 不是 graph-neutral 的。对一份能干净解析的 torture 文件做全树 diff 共 278 行,全部归类。bump 门禁的要求变成:"diff 恰好由以下类别构成,别无其他"。

改变行为的四类(bump 门禁必须恰好显示这些):

  1. 匿名类获得包装节点。 旧语法下 new class … { }base_clause/class_interface_clause/declaration_list 直接挂在 object_creation_expression 下;新语法把它们嵌套进一个 anonymous_class 子节点。后果(对应 TS 侧 findAnonymousClassBodyextractInstantiation 分支):
    • 旧行为:declaration_list 是直接子 → 产出匿名类节点 <T$anon@line> + extends ref + 方法节点;新行为(walker 实现的):findAnonymousClassBody 找不到 → 不产匿名类节点、不产 extends ref;walker 继续下探——顶层时内部 method_declaration 命中 methodTypes 分支、失败 isInsideClassLikeNode 门禁,被提取为文件级 function 节点;在函数体内时,body walker 没有 methodTypes 分支,匿名类方法整体消失,其内部调用归属到外层函数。
    • extractInstantiation 取 ctor(namedChild(0),无 field):旧 = base_clausedeclaration_list;新 = 整个 anonymous_class → className 变成整段类源码文本走一遍 <-strip + ./:: 后缀逻辑——"两种形状各异的垃圾,但都是垃圾"。walker 必须精确复刻新形状。这在 php.rsextract_instantiationstrip_generic_and_qualifier#L1510-L1529)中落实,注释明确"garbage, deterministic — preserve"。
  2. 分组导入的嵌套子句丢失。use A\{Sub\Deep} 的子句形状是 namespace_use_group_clause > namespace_name > name…——内联分支能"找到" namespace_name 并为 A\Sub 产出 import 节点 + ref(错误但旧的形状);新形状是 namespace_use_clause > qualified_name,分支里 find(type === 'name') 找不到任何东西 → 该子句被静默跳过(无 import 节点、无 ref)。简单成员(Mailer)与别名成员(Cache as CacheAlias——子节点 name, as, alias: name,find 返回第一个 name 即源名)在新旧两版上行为一致。walker 在 php.rs#L923-L961 的分组分支中复刻了这个 SKIP。
  3. 旧语法解析报错的文件现在能解析了。 enum-const 文件(monolog 的 Level.php)从 mangled/error 提取变为干净——此类文件上的 node/edge 差异正是 bump 按预期工作。
  4. (bump 门禁时新发现,调研时漏掉的)PHP 8.4 无括号 new X()->m() 链误解析修复。 旧语法把整条链解析为一个 object_creation_expression 且无 error flag(所以错误矩阵探测不到)→ 产出 X()->m 形状的垃圾 instantiates ref;新语法正确解析为 member_call_expression(object_creation_expression(X), m) → 得到规范的 instantiates X + call ref。symfony 中此类 ref 有 86 处。精度上是正向的,与 ruby 的 &.!= 同类。

Ripple 说明(实测): 除上述四类之外,full-init dump diff 还带"解析 ripple"——因 category 3 恢复了 Request.php/Response.php 等符号,图里多了符号,导致数百个其他文件里原本停在 unresolved_refs 表的 ref 翻转为已解析边。文档给出机械可证的方法:所有"仅一侧"的 parked ref(不在 1/3/4 类文件内的)都与另一侧一条 resolved edge 一一对应(同 source/refName/line/col),且那些文件之外的 node 行字节稳定(monolog 3/0 未配对,framework 26/0,symfony 2,132 条未配对全部属于 category 4)。结论:不要逐文件重审 ripple hunk。

惰性差异(已对每个消费分支验证): qualified_name 内部的 namespace_name_as_prefix 包装、namespace_use_clause 新增 type: field、单条别名 use X as Y 的扁平化、property_elementname: field 化、anonymous_function_creation_expressionanonymous_function 改名(TS 侧没有任何代码引用这两个类型名)、primitive_type 变叶子、text_interpolation?> 命名化、namespace_definitionname: field、new static()/self()/parent() 的 name 叶子化、enum_casevalue: field、attributes 形状两版一致——全部惰性,walker 按新版形状实现即可。

4. 架构决策:五条"不做/必做"的边界

文档 §Architecture decisions 划定 walker 的职责边界,每一条都能在当前代码里验证:

  1. 无 preParse。 languages/php.ts 没有 preParse 钩子,preParsedSource 对 php 是 no-op,两条 arm 都解析原始字节,没有需要上提的预处理。
  2. Laravel/Drupal 仓库走解码路径,三个门禁仓库不走。 laravelResolverlaravel.ts,detect = artisan 文件存在或 app/Http/Kernel.php)与 drupalResolverdrupal.ts)都有 extract() 钩子,parse-worker 会把任何命中适用框架 extract() 的语言强制解码路径。monolog / laravel-framework / symfony 三个门禁仓库都不触发任一检测器,其 php 文件走 raw-buffers 传输。由此两条反推禁令:不要从 Laravel APP 仓库得出"raw 路径坏了",也不要从门禁仓库得出"框架钩子是死代码"。
  3. 框架提取器本身无需移植(对原始源码跑正则),但它们钉住了 walker 的输出契约——drupal 会用 generateNodeId(filePath, 'function', name, line) 重构函数节点 ID(见 §8)。
  4. 一个 walker 模块 codegraph-kernel/src/php.rs(约 1,536 行),在 langs.rs 注册;每文件 has_error()defer:。骨架参照物:java.rs 是最接近的样板(类作用域栈、字段、enum、带钩子的 import、静态成员 ref、装饰器 no-op、value ref);rustlang.rs 是"钩子抑制 import fallback"与 node_ids 去重模式的样板。
  5. 扩展名.php,以及 Drupal 集合 .module/.install/.theme/.inc在 detectLanguage 阶段全部映射到php——无内容嗅探、无方言。扫描与 fixture 必须包含非 .php 扩展名文件。MAX_FILE_SIZE`(1 MiB)与生成文件跳过是 orchestrator/TS 侧共享逻辑,无 php 专属项。
  6. 无 POST_PASSES 条目tryKernelExtractRaw 保持可用。

5. TS 侧提取器配置:walker 必须"精确移植"的钩子

languages/php.ts(189 行)是 walker 的行为规格书。节点类型映射:

functionTypes = ['function_definition']
classTypes    = ['class_declaration', 'trait_declaration']   // classifyClassNode → trait 归 'trait'
methodTypes   = ['method_declaration']
interfaceTypes= ['interface_declaration']
enumTypes     = ['enum_declaration']    enumMemberTypes = ['enum_case']
importTypes   = ['namespace_use_declaration', include/require 的 4 种表达式]
callTypes     = ['function_call_expression', 'member_call_expression',
                 'scoped_call_expression']          // 注意:不含 nullsafe_member_call_expression
variableTypes = ['const_declaration']                // 死配置:visitNode 钩子先消费
fieldTypes    = ['property_declaration']
nameField='name', bodyField='body', paramsField='parameters', returnField='return_type'

存在的钩子(逐个精确移植):

  • getReturnType = extractPhpReturnTypephp.ts#L50-L66):取 return_type field;optional_type 解包到 namedChild(0)primitive_type → undefined;named_type 取首个命名子。文本 trim 后剥前导 \;取最后一个 \ 段;小写 ∈ {self, static, this, this} → 返回标记 **`'self'`**(chained-call 机制在解析期把它还原为声明类);小写 ∈ `PHP_NON_CLASS_RETURN`(array/string/int/integer/float/double/bool/boolean/void/mixed/never/null/false/true/object/callable/iterable/resource,[php.ts#L37-L41](https://gitcode.com/GitHub_Trending/co0degr/codegraph/blob/6a056ec5db35172f9dc348f87b54ea415aa5169e/src/extraction/languages/php.ts?utm_source=gitcode_repo_files#L37-L41))→ undefined;不匹配 `/^[A-Za-z_]\w*/→ undefined(**杀掉A|B联合——union_type 既不是 optional 也不是 named_type,文本A|B过不了正则**)。探测确认:v0.24.2 上: self: static都是named_type > name→ 标记'self' 对两者都生效;: ?FooFoo: \App\Models\User→ 剥前导` 后取末段 User。walker 实现见 php.rs#L446-L474,与 TS 逐行同构。
  • classifyClassNodetrait_declaration → 'trait',其余 'class'。
  • getVisibilityphp.ts#L89-L100):扫描所有子节点(含匿名子)找 visibility_modifier;文本精确等于 public/private/protected 才采用;无修饰符 → 'public'(PHP 默认)。final/abstract/readonly_modifier 按类型跳过。
  • isStatic:任意 static_modifier 子 → true。
  • visitNode 钩子php.ts#L108-L144):见下一节。注意它只在主 walker 的 visitNode 处触发,在 visitFunctionBody 的 walker 中触发。
  • packageTypes + extractPackagephp.ts#L150-L156):namespace_definition → 找 namespace_name;带 body(compound_statementdeclaration_list 子节点)→ null(大括号命名空间不产节点、不作用域化);否则返回命名空间文本。

不存在的钩子(walker 不得做这些事)preParsegetSignaturephp 函数/方法节点没有签名——undefined)、isAsync(undefined 而非 false)、isConstisExportedresolveNamegetReceiverType恒 undefined → 无 receiver 限定名组合、无 owner-contains fallback)、classifyMethodNodesynthesizeMembersskipBodilessClass无 body 的 class_declaration 仍然产节点)等。php.rsextract_function/extract_methodsignature: None 正对应这一条。

6. visitNode 钩子:常量与 trait-use 的两个"钩子消费"分支

这是 php.ts 中最微妙的一段(walker 实现:php.rs#L476-L528),两个分支返回 true 表示"已消费"——主 walker 随即 scanFnRefSubtree(node, 0)不下探

分支一:const_declaration(任何作用域——顶层、类、接口、trait、enum)。 对每个 const_element 命名子,取其中第一个 name 类型子(const A = OTHER_CONST 的值也是 name 节点但在后面)→ ctx.createNode('constant', name, elem, {})位置是 const_element,一个元素一个节点(const A = 1, B = 2 → 两个 constant 节点);extra 为空 → 无 docstring、无签名、无可见性、无 isStatic——final public const int X = 5 也不携带任何修饰信息。由于钩子消费后不下探,常量值永远不被遍历(常量初始化器里的调用/实例化不产 ref)。Contains 边来自 nodeStack 顶。

分支二:use_declaration(类体内的 trait 使用)。 名字 = 命名子中过滤 type === 'name' || type === 'qualified_name'——只有被使用的 trait 名use_list 冲突块 { A::g insteadof B; B::g as protected h; } 类型是 use_list,被过滤掉;探测确认其内部节点不是直接子)。parentId = nodeStack 顶(类);每个名字产一条未解析 ref:referenceKind: 'implements'行/列取整个 use_declaration 节点use A, B; 中所有名字同一位置)。钩子同样消费后不下探——insteadof/as 子句永不提取(无别名方法节点、无冲突消解边)。

值得注意的是 R7b 期间的一个协议升级:trait-use 的 implements ref 通过 v2 REF_FLAG_FILE_PATH 线槽携带 filePath(与 ruby 移植同批发)。php.rs#L500-L524push_ref_flagged(..., REF_FLAG_FILE_PATH) 就是这一点的落实——TS 侧对应 filePath: ctx.filePathphp.ts#L130-L137)。

7. 节点 ID、命名空间捕获与发射顺序

ID 公式。 createNode 的 id = generateNodeId(filePath, kind, name, startRow+1) = `${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex.slice(0,32)}`;文件节点 id 是字面量 file:${filePath}。去重/自检比较的是 ID 字符串(node_ids 向量模式)。php.rs#L314-L405create_node 完整复刻:end_line 直接取节点末行(php 无 resolveBody 钩子)、qualified name 用 :: 连接栈中非 file 作用域名、每个节点推一条 contains 边(source = 栈顶)。

命名空间捕获。 遍历开始前,扫描根节点直接命名子,取第一个 namespace_definition(命中即 break——多命名空间文件把一切归入第一个);extractPackage 判定无大括号 body 后创建 namespace 节点——它是 file 节点之后的第 2 个节点,与声明位置无关(即使出现在 declare(strict_types=1) 之后),并在整个 walk 期间压在栈上:所有顶层符号的 qualifiedName 变成 App\Services::Name,方法为 App\Services::UserService::run。这正是 use ref(Foo\Bar::Baz)解析所对撞的命名空间前缀。walker 对应实现:php.rs#L214-L244

带 attributes 的声明从 #[ 行开始。 #[Registry]\n class UserService 的 class 节点位置是 #[ 行——它同时影响 ID 公式的行号输入与 drupal 的函数 ID 重构(§8)。

发射顺序 = TS walk 顺序(存储/测试框架对 rowid 顺序敏感):file 节点 → namespace 节点(若有)→ 源码顺序(每构造:节点 + contains 边 → 该节点按提取器顺序的 refs)→ fn-ref refs(flushFnRefCandidates)→ value-ref 边(flushValueRefs)。Refs 与 nodes 的交织必须与 TS 调用点一致(继承 ref 在 body ref 之前;方法的类型 ref 在 body 调用之前)。

8. 调用引用的编码:PHP 的"callee 文本"规则

extractCall 对 PHP 走两条路径(walker:php.rs#L997-L1063)。入口:name field 存在且 object/scope field 存在 → 分支 A(member/scoped call);否则分支 B(function_call_expressionfunction field 的原始文本)。

分支 A 的规则(全部要精确复刻):

  • Fluent 静态工厂 Cls::factory($x)->method():object 是 scoped_call_expression 时,callee = `${scope}::${name}().${method}`内部参数丢弃——UserModel::query().where),emit 后返回;同时内层 scoped call 会被 walker 递归再访问 → 同时产出 UserModel.query ref("两个都发,与 rust 链一致")。scope 文本可以是 self/static/限定名,原样输出。
  • receiver = 对象原始文本剥一个前导 $$x->m()x.m(供解析侧 local-receiver 推断 / typed-param 推断消费);$this->m() → receiver ∈ SKIP_RECEIVERS {self, this, cls, super, parent, static} → 裸 m$this->prop->m()this->prop.m——整个 #1251 属性接收者机制是解析侧的(name-matcher 中剥离 this->、按声明类型推断、带影子防护),提取侧唯一职责是精确的 this->prop.m 编码 + 行/列。更深的 $this->a->b->m()this->a->b.m(解析器不会匹配,保持未解析)。
  • 实例链保留参数$this->factory()->m()this->factory().m / this->factory($cfg).m(只有 scoped fluent 分支归一化,"fluent 第二跳"缺口是未修复状态)。
  • 字面量 receiver 不抑制"chain"->upper() 产出 callee "chain".upper(垃圾 ref,永不解析——保留)。
  • scoped 调用是点号连接self::m()/static::m()/parent::m() → 裸 m$var::m()var.m\App\Util::go()\App\Util.go(前导 \ 保留——保留)。注意 UserModel::m() 产出的是 UserModel.query 式的点号 ref(UserModel.m),绝非 UserModel::m——laravel 的 Model::method 解析模式只会从其他发射器(fn-ref 字符串 callable、use ref)看到 :: 形状的 ref。

分支 B: callee = function 字段原始文本——裸名 helper、限定名 \App\Helpers\format_id(反斜杠原样,下游不可解析——保留)、变量 callee $fn、first-class callable format_id(...)format_id(function-ref 规格故意依赖这一点)。

nullsafe ?-> 不产任何东西nullsafe_member_call_expression 不在 callTypes 中,走递归——内部参数调用仍会提取(#1251 follow-up 有意未发货,固定当前行为)。

9. 实例化、静态成员读取与继承

new 表达式(walker:php.rs#L1065-L1085):php 无 constructor/type/name field(探测确认),取 namedChild(0) 原文走 strip_generic_and_qualifier(截第一个 < 后取最后 ./:: 段——不处理反斜杠):

  • new UserModel()UserModel
  • new \App\Models\User() → 保留完整限定文本含前导 \(保留;解析侧处理或丢弃);
  • new static()/self()/parent() → 字面量 static/self/parent 的 instantiates ref(不可解析——保留);
  • new $cls() → ref 名 $cls$ 保留——只有 extractCall 剥 receiver 的 $);
  • 匿名类 → 整段源码文本走完归一化 → 一个确定性垃圾 ref(在 fixture 中钉住精确字节);
  • 签名参数默认值里的 new NullMailer() 不产任何东西——方法 walk 只覆盖 body field,formal_parameters 只由类型引用 walker 遍历。

静态成员/值读取(php 在 STATIC_MEMBER_LANGS 中;walker:php.rs#L1124-L1167):仅在 body walker 中调用(顶层读取不产任何东西)。class_constant_access_expression 无 field → 取 namedChild(0),属可接受类型且文本匹配 ^[A-Z][A-Za-z0-9_]*$ → 在接收者位置产出 references ref:UserModel::class / Foo::CONST / Suit::Hearts → ref 到 UserModel/Foo/Suitself::CONST(relative_scope)→ 无;UserModel::$conn(有 scope: field)→ ref UserModel\App\Models\User::class(qualified_name 接收者)→ 无(保留);小写接收者 → 无。

继承(walker:php.rs#L1169-L1190):

  • base_clause(extends):取 namedChild(0)——只有第一个基类。类没问题(单继承),但 interface I extends A, B, C 丢掉 B 和 C(探测确认 base_clause 子 = [name, qualified_name, name])。限定首基类保留完整文本(\Foo\Bar)。保留丢弃。
  • class_interface_clause(implements):取全部命名子,每 name/qualified_name 一条 implements ref、各带完整文本(\JsonSerializable 含反斜杠)。enum implements 走同一子句。
  • trait-use 的 implements ref 来自 visitNode 钩子(§6),不来自这里。

类型注解引用(php 在 TYPE_ANNOTATION_LANGUAGES 中;walker:php.rs#L1194-L1242):对每个 FUNCTION 与 METHOD 节点跑 extractPhpTypeRefs——参数走 formal_parameters 的每个参数(含 property_promotion_parameter),返回/直接位置扫声明的命名子;PHP_TYPE_NODES(named/optional/nullable/union/intersection/DNF/primitive)逐一 walkPhpTypePositionprimitive_type 无产出;name 文本不在伪类型表(self/static/parent/mixed/object/iterable/callable/void/null/false/true/never/array/int/float/string/bool)→ 该位置一条 referencesqualified_name最后一个 \(非伪)→ ref 在 qualified_name 位置;包装类型递归。于是 ?LoggerLoggerMailer|NullMailer → 两个;(A&B)|C → A、B、C。属性类型提示从 field 节点不产任何 ref(php 字段分支在 extractDecoratorsFor/extractTypeAnnotations 之前就 RETURN)——类的方法承载 php 类型 ref,属性不承载。

字段提取php.rs#L800-L854):property_declaration 在类内时,typeNode = 第一个不属于 {visibility, static, readonly, property_element, var_modifier} 的命名子——怪癖保留:final_modifier/abstract_modifier 不在排除集final public Foo $x(8.4 final 属性)会把 final 当作类型文本。每个 property_element:名字取 variable_name 下的 name$),signature = `${typeText} $${name}`$${name}$ 只在签名里加回);一个元素一个 field 节点,然后 RETURN。构造器 promotion 参数(property_promotion_parameter不是字段——无节点(其类型提示走类型 ref)。

10. Imports:四种 PHP 导入形状

php.ts#L157-L188extractImport 钩子优先,walker 实现见 php.rs#L868-L993

  1. include/require(+_once)php.ts#L22-L34 phpStaticIncludePath):参数 = namedChild(0)parenthesized_expression 解包一层;必须是 string|encapsed_string 且所有命名子都是 string_content任何插值/转义 → null)。静态 → import 节点名为路径 + 一条 imports ref,fromNodeId = nodeStack 顶(有文件级命名空间时是命名空间节点),位置取 include 节点。动态(require __DIR__ . '/x'、变量)→ 钩子 null → 落入 if (extractImport) return 守卫 → 什么都不产。消费方是 resolveIncludePathimport-resolver.ts 中的路径形 imports ref:后缀/相对文件匹配,缺 .php 补上)。
  2. 单条 use(含 use function/use const/别名):钩子取 namespace_use_clause → 其 qualified_name(完整文本,不含别名)否则 name(裸单段导入,如 use Countable;)→ import 节点 + 通用 imports ref;随后 php 专属的 emitPhpUseRefs/pushPhpUseRef:剥前导 \不再含 \ → 返回(裸 use Countable; 只产通用 ref,无 :: ref);否则 ref 名 = 末段 \::App\Contracts\LoggerApp\Contracts::Logger),行/列取声明节点。
  3. 分组 use A\{B, C as D, Sub\E}:钩子见 namespace_name + namespace_use_group → 返回 null → 内联分支:prefix = namespace_name 文本;每个子句取第一个 name源名,别名跳过)→ import 节点名 ${prefix}\${name}(位置在整个声明上,signature = 全文)+ pushPhpUseRef(fullPath)A::B ref。嵌套 Sub\E 子句在新语法下是 qualified_name 子 → 整个子句跳过(§3 差异 #2)。多个 import 节点共享声明位置。
  4. 其他钩子 null 情形 → 无产出(无通用 fallback)。

fn-ref 门禁的细节use ref 文本同时含 \::,而 QUALIFIED_IMPORT 正则完全拒绝 : 字符 → App\Contracts::Logger 不匹配、贡献空;include 路径含 / 也不匹配。净效果:php 的 fn-ref 门禁是"本文件定义 ∪ 裸单段 use 导入 ∪ skipGate 候选"

11. Value-reference 边与 function-as-value 捕获

Value refs(walker:php.rs#L1414-L1474):CODEGRAPH_VALUE_REFS=0 可全局关闭;MAX_VALUE_REF_NODES = 20,000 封顶剪枝 DFS 与每次 reader 扫描;生成文件跳过。

  • Target(createNode 内 captureValueRefScope):kind 为 constant|variable 且名长 ≥3 且匹配 /[A-Z_]/父 ID 前缀 ∈ {file:, class:, module:, struct:, enum:}。php 只产 constant。这里藏着一个被刻意保留的怪癖:命名空间文件里,顶层 const 的父是 namespace: 前缀节点——不在接受集里 → target 被丢弃;无命名空间文件(drupal .module、脚本)保留。类 const(class:)与 enum const(enum:)合格;interface/trait const 不合格。
  • Shadow prune 对 php 是 no-op:declarator 分支表里没有能解析 php 的 case(assignment 是 Python 的节点;property_declaration 匹配 php 节点类型但走的是 Kotlin/Swift 提取路径 → null),declCounts 恒空 → 没有 php target 被剪枝。walker 要么跑一个字节相同的空 DFS,要么跳过——两者字节一致。
  • 发射:对每个 reader 作用域(function/method/constant 节点)DFS 其子树,匹配 name 类型节点(php 专属 reader 类型)文本命中 target → 去重后一条 references 边(metadata {valueRef:true})。因为每个 php name 节点都会匹配——常量读取、self::MAX 的常量半部、$MAX_RETRIES 变量名内的 name、成员名、字符串插值——reader 子树内目标名的任何文本出现都会发边(精度依赖 target 名的 [A-Z_]-ish 门)。

Function-as-value(#756):PHP_SPEC 的 idTypes = ∅(裸标识符永不是候选);dispatch 只在 arguments 节点;特殊层为 encapsed_string/string/array_creation。规则(walker:php.rs#L1261-L1322):

  • 字符串 callable:仅当外层调用(≤4 层父跳至 function_call_expression,遇 member/scoped call 中止——方法调用 HOF 永不入围)名 ∈ PHP_CALLABLE_HOFS(array_map/filter/walk[_recursive]/reduce、usort/uasort/uksort、array_udiff[_assoc]、array_uintersect[_assoc]、call_user_func[_array]、forward_static_call[_array]、preg_replace_callback[_array]、register_shutdown_function、register_tick_function、set_error_handler、set_exception_handler、spl_autoload_register、ob_start、iterator_apply、header_register_callback、is_callable——完整列表固化在 php.rs#L73-L87)。内容 = string_content trim 后文本;匹配简单名或 Cls::method 形状 → 候选,skipGate: true(flush 时绕过 definedHere/imports 门)。怪癖:命名空间字符串('App\Svc\fn')两个正则都不匹配 → 丢弃。
  • 数组 callable(任何调用的参数,无 HOF 门):恰好 2 元素;el1 是简单名字符串;el0 是 $this → 候选 this.<m>(恒 flush);el0 是 Foo::class(namedChild(1) 文本 === class)→ Foo::m(恒 flush);['Cls', 'm'](字符串 receiver)→ 无。
  • Flush 去重键 ${fromNodeId}|${name},referenceKind function_ref

12. 框架提取器:留在 TS 侧,但钉住 walker 的输出契约

  • laravelResolverlaravel.ts):detect artisan/app/Http/Kernel.php。extract() 只对 .php 文件,对剥注释后的源码正则 Route::METHOD(...)/Route::resourceroute 节点,ID 是字面量(不哈希)+ handler ref(Cls@method/Cls)。resolve() 消费 Model::method(只有 fn-ref 字符串 callable / use ref 会产出 :: 形状)与 Controller@method。对 walker 的依赖仅是方法/类节点的名字与 kind。
  • drupalResolverdrupal.ts):languages ['php','yaml']。.routing.yml → route 节点;hook 文件(.module/.install/.theme/.inc)与所有 .php→ hook ref,其 fromNodeId 是**重构**出来的generateNodeId(filePath, 'function', funcName, lineNum),lineNum 是 ^function\s+(\w+)\s*(正则匹配的行——**walker 的函数节点 ID/行号必须字节级匹配,否则每条 Drupal hook 边都悬空**(带 attribute 的函数今天就已不匹配:正则找到function行而节点从#[` 行开始——这是保留的线上真相)。

13. Parity 机制:每一条都"咬过人"

  • 发射顺序按 §7;
  • generateNodeId 输入:(filePath, kind, name, startRow+1)——字段名无 $;import 节点名是完整的 App\Contracts\Logger;命名空间节点名是包名;行 = 声明起始(有 attributes 时是 attribute_list 起始;const 是 const_element 行;field 是 property_element 行;分组 import 是整条声明行);
  • UTF-16 列与切片textutil::col16/slice_utf16):所有 ref/node 列、startIndex/endIndex 子串(getNodeText)、include-path/类型/签名文本——php 源码充满多字节字符串,torture fixture 需要符号前的一行非 ASCII;
  • CRLF:探测确认 v0.24.2 扫描器对 CRLF heredoc/nowdoc/docblock 解析干净且与旧版一致;唯一 CRLF 敏感的 TS 逻辑是 cleanCommentMarkersgm 逐行剥离(^#\s?^\s*\*\s? 等——php # 注释在这里)→ 走 js_multiline_strip(#1329 的 CRLF ^-after-\r 语义)。CRLF 变体在内存中派生,与 tsjs 模式一致;
  • Defer 策略:每文件 has_error()defer:——wasm 恢复是规范。新语法下预期发生率 ≈0.0–0.1%;
  • Docstrings:php 注释(//#/* *//** */)全是 comment 节点类型;attributes 打断 docstring 链(它们在声明节点内部,对照 rust 的 attribute_item 怪癖);/** doc */ #[Attr] class C 保留 docstring。Docstring 挂到 function/method/class/interface/enum/property,挂到钩子产的 constant、enum_member 或 import 节点。

14. 门禁与验证(per plan §5,无例外)

文档 §Gates 的五步门禁,全部已在仓库中留下可验证痕迹:

  1. 语法 bump 先行独立落地(wasm vendor + =0.24.2 pin + VENDORED_WASM_LANGS + kernel-grammar-parity 的 GRAMMAR_LANGUAGES += 'php' 一个变更内),全套件绿,在任何 walker 存在之前。旧 vs 新 wasm 的 full-init dump diff 预期非空——每个 hunk 必须归入 §3 的类别,其他类别阻断 bump。
  2. Torture fixtures:仓库中实际存在 torture.phpTortureModule.module(drupal 扩展名路由 + hook 文档块)、TortureHtml.php(前置/交错 HTML + <?=),CRLF 变体内存派生,外加一个故意语法错误的 defer fixture(真损坏语法如未闭合 function f( {——不是 8.4 特性,那些在 v0.24.2 上解析干净)。
  3. Parity 扫描scripts/kernel-parity.mjs <dir>(顺序敏感的 full-object 比较、--max-deferral 0.1)在 monolog(217 文件)/ laravel-framework(2,999)/ symfony(10,736)三个仓库上 0 差异——kernel/index.ts#L67-L69 的注释记录了结果;随后 full-init dump-diff 三仓库字节一致(kernel arm vs CODEGRAPH_KERNEL=0scripts/dump-graph.mjs + cmp)。
  4. 套件tests/kernel-php-parity.test.ts——把 torture + CRLF 变体 + leading-HTML + defer fixture 的 kernel↔wasm 一致性(nodes、edges、unresolved refs 规范化多重集比较)钉进 npm test;无 kernel 二进制时跳过,CODEGRAPH_KERNEL_EXPECT=1 时跳过变失败。
  5. DEFAULT_ROUTED += 'php' 在上述全部完成之后(已落地,kernel/index.ts),changelog 搭既有 kernel 条目。Post-route 健全性检查:门禁仓库走 raw 路径,所以另需一个带 artisan 的 Laravel APP 与一个 Drupal module 做解码路径冒烟(drupal hook-ID 重构仍要命中——一个带 hook docblock 的 .module fixture)。

torture.php 的清单值得作为"fixture 设计"参考单独摘录——每一行都标注了它钉住的分支:文件级命名空间(+ 第二个被忽略的 namespace_definition、大括号形式 → 无节点);use 的四种形态 + 分组含别名成员与嵌套 Sub\Deep SKIP;include/require ×4(含带括号 + 动态 → 无);接口多 extends(只取首个);trait 声明 + use A, B { insteadof / as }(use 行 2 条 implements ref,再无其他);backed + pure enum + implements + method + enum 内 const;多元素/带类型/final 的类 const + 顶层 const(只在无命名空间时才是 value-ref target);属性各形态(typed/nullable/union/readonly/var/多元素/static);promotion 构造器(只有类型 ref,无 field 节点,new 默认值无产出);方法(默认 'public'、static、abstract 无 body、: self/: static → 'self'、: ?Foo: Foo|Bar → undefined、: void → undefined);body 内嵌套命名函数与 polyfill 式条件类;闭包与箭头函数(调用归属外层、无节点);FCC 三形态(普通 calls ref);调用形状全家桶(裸、限定、$x->m$this->m 裸、$this->prop->mthis->prop.m、双跳、Cls::m → 点号 Cls.m、self/static/parent 裸、$var::m\Qual\Cls::m、fluent Cls::factory().m + 内层 Cls.factory$this->factory().m 保参变体、nullsafe ?-> 无产出、字面量 "x"->upper());实例化各形(限定全文、static/self/parent 字面、$cls、ctor 参数调用递归、匿名类顶层 + 体内两作用域);静态成员读取(Cls::CONSTCls::classCls::$propself::CONST 无、\Q\Cls::CONST 无、enum Suit::Hearts);match 表达式、$$var、插值/heredoc/nowdoc;fn-ref 正例反例(usort($a,'cmp')array_map('A\B\f',…) 丢弃、call_user_func([$this,'m'])[Foo::class,'m']['Cls','m'] 丢弃、方法调用 HOF 丢弃);value ref(含 $CONST_NAME 变量出现与插值读取);docblock 各形态 + attribute 不断链 + 带 attribute 声明的节点行号 = #[ 行;符号前的非 ASCII 行。

15. 这套 checklist 的方法论价值

回过头看,这份 PHP 移植文档的示范意义不在 PHP 本身,而在它展示了一套"解释器双实现一致性迁移"的完整操作:

  1. 先升级依赖再移植逻辑,且把升级 diff 当作一等公民——PHP 的 bump 不中性,所以门禁从"期望零 diff"改成"枚举 + 分类 + ripple 可机械证明",避免团队在数百个 ripple hunk 上无谓消耗;
  2. 把"保留哪些 bug"写成规格——垃圾 ref 的精确字节、被丢弃的多继承基类、命名空间文件的常量 target 丢弃,全部以 PRESERVE 标注进入 walker 注释与 fixture,让"bug-for-bug"从口号变成可测试断言;
  3. 门禁分层:torture fixture(分支级)→ 全仓库扫描(文件级 0 差异)→ full-init dump 字节比对(图级)→ DEFAULT_ROUTED 路由开关(发布级),每层失败信号不同(fixture 失败 = 分支错;扫描 deferral 超阈 = walker 或语法问题;dump 不一致 = 顺序/ID/编码问题);
  4. TS 侧框架提取器不迁移但契约化——walker 的节点 ID 与行号公式成为跨实现 API,drupal 的 ID 重构是契约中最脆的一环,被显式点名。

对要在自己的项目里做"旧实现 → 高性能新实现"迁移的读者,这套 checklist 可以直接当模板:先写满"每个分支做什么/不做什么"的表格(含 file:line 锚点),再写"语法/依赖升级差异分类",最后写"门禁与 fixture 清单"——三者齐备,移植期的每一行 diff 都有归属。

登录后查看全文
热门项目推荐
相关项目推荐