CodeGraph PHP 内核移植实战:从 wasm 提取器到 Rust 原生 Walkers 的 bug-for-bug 行为一致性
本文基于 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 要求,是三件事按序落地:
- 语法 bump 先行(在任何 Rust walker 存在之前):把生产加载的 2023 年 ABI-14 版 wasm 替换为 v0.24.2,并枚举分类新旧 dump 的全部差异;
- Parity 门禁:torture fixture 套件 + 三个真实仓库的全量扫描 0 差异 + full-init dump 字节一致 ×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.rs 的 LANGUAGES 常量已含 "php"(共 20 种语言),grammar_for 对 "php" 返回 tree_sitter_php::LANGUAGE_PHP(langs.rs#L65-L67);Cargo.toml#L41-L43 以 tree-sitter-php = "=0.24.2" 精确 pin 住版本,注释重申"NEVER LANGUAGE_PHP_ONLY"。
2. 语法准备:选对变体、选对版本、只用 check-in 的 parser.c
这是整份 checklist 的第一步,也是最容易踩坑的一步。文档给出三个约束:
变体:完整的 php 语法,而不是 php_only。 tree-sitter-php 仓库是双语法结构,crate 同时导出 LANGUAGE_PHP 与 LANGUAGE_PHP_ONLY。调研阶段通过探测确认:当前生产 wasm 能解析混合 HTML+PHP 文件,根节点出现 text / php_tag / text_interpolation 节点且无错误——这正是 php/ 变体的行为。php_only 变体对任何前置 HTML 直接 ERROR,而"PHP 文件带 HTML 头"是 Drupal/遗留系统的常态。这条结论在 Cargo.toml#L41-L43 和 langs.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.c59ad8e5e…d27ec6php/src/scanner.c58c92caf…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 门禁必须恰好显示这些):
- 匿名类获得包装节点。 旧语法下
new class … { }把base_clause/class_interface_clause/declaration_list直接挂在object_creation_expression下;新语法把它们嵌套进一个anonymous_class子节点。后果(对应 TS 侧findAnonymousClassBody与extractInstantiation分支):- 旧行为:
declaration_list是直接子 → 产出匿名类节点<T$anon@line>+extendsref + 方法节点;新行为(walker 实现的):findAnonymousClassBody找不到 → 不产匿名类节点、不产 extends ref;walker 继续下探——顶层时内部method_declaration命中 methodTypes 分支、失败isInsideClassLikeNode门禁,被提取为文件级function节点;在函数体内时,body walker 没有 methodTypes 分支,匿名类方法整体消失,其内部调用归属到外层函数。 extractInstantiation取 ctor(namedChild(0),无 field):旧 =base_clause或declaration_list;新 = 整个anonymous_class→ className 变成整段类源码文本走一遍<-strip +./::后缀逻辑——"两种形状各异的垃圾,但都是垃圾"。walker 必须精确复刻新形状。这在 php.rs 的extract_instantiation与strip_generic_and_qualifier(#L1510-L1529)中落实,注释明确"garbage, deterministic — preserve"。
- 旧行为:
- 分组导入的嵌套子句丢失。 旧
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。 - 旧语法解析报错的文件现在能解析了。 enum-const 文件(monolog 的
Level.php)从 mangled/error 提取变为干净——此类文件上的 node/edge 差异正是 bump 按预期工作。 - (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_element 的 name: field 化、anonymous_function_creation_expression → anonymous_function 改名(TS 侧没有任何代码引用这两个类型名)、primitive_type 变叶子、text_interpolation 的 ?> 命名化、namespace_definition 的 name: field、new static()/self()/parent() 的 name 叶子化、enum_case 的 value: field、attributes 形状两版一致——全部惰性,walker 按新版形状实现即可。
4. 架构决策:五条"不做/必做"的边界
文档 §Architecture decisions 划定 walker 的职责边界,每一条都能在当前代码里验证:
- 无 preParse。 languages/php.ts 没有
preParse钩子,preParsedSource对 php 是 no-op,两条 arm 都解析原始字节,没有需要上提的预处理。 - Laravel/Drupal 仓库走解码路径,三个门禁仓库不走。
laravelResolver(laravel.ts,detect =artisan文件存在或app/Http/Kernel.php)与drupalResolver(drupal.ts)都有extract()钩子,parse-worker 会把任何命中适用框架extract()的语言强制解码路径。monolog / laravel-framework / symfony 三个门禁仓库都不触发任一检测器,其 php 文件走 raw-buffers 传输。由此两条反推禁令:不要从 Laravel APP 仓库得出"raw 路径坏了",也不要从门禁仓库得出"框架钩子是死代码"。 - 框架提取器本身无需移植(对原始源码跑正则),但它们钉住了 walker 的输出契约——drupal 会用
generateNodeId(filePath, 'function', name, line)重构函数节点 ID(见 §8)。 - 一个 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去重模式的样板。 - 扩展名:
.php,以及 Drupal 集合.module/.install/.theme/.inc在 detectLanguage 阶段全部映射到php——无内容嗅探、无方言。扫描与 fixture 必须包含非.php扩展名文件。MAX_FILE_SIZE`(1 MiB)与生成文件跳过是 orchestrator/TS 侧共享逻辑,无 php 专属项。 - 无 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 = extractPhpReturnType(php.ts#L50-L66):取
return_typefield;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'对两者都生效;: ?Foo→Foo;: \App\Models\User→ 剥前导` 后取末段User。walker 实现见 php.rs#L446-L474,与 TS 逐行同构。 - classifyClassNode:
trait_declaration→ 'trait',其余 'class'。 - getVisibility(php.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 + extractPackage(php.ts#L150-L156):
namespace_definition→ 找namespace_name;带 body(compound_statement或declaration_list子节点)→ null(大括号命名空间不产节点、不作用域化);否则返回命名空间文本。
不存在的钩子(walker 不得做这些事):preParse、getSignature(php 函数/方法节点没有签名——undefined)、isAsync(undefined 而非 false)、isConst、isExported、resolveName、getReceiverType(恒 undefined → 无 receiver 限定名组合、无 owner-contains fallback)、classifyMethodNode、synthesizeMembers、skipBodilessClass(无 body 的 class_declaration 仍然产节点)等。php.rs 中 extract_function/extract_method 的 signature: 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-L524 中 push_ref_flagged(..., REF_FLAG_FILE_PATH) 就是这一点的落实——TS 侧对应 filePath: ctx.filePath(php.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-L405 的 create_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_expression 取 function field 的原始文本)。
分支 A 的规则(全部要精确复刻):
- Fluent 静态工厂
Cls::factory($x)->method():object 是scoped_call_expression时,callee =`${scope}::${name}().${method}`(内部参数丢弃——UserModel::query().where),emit 后返回;同时内层 scoped call 会被 walker 递归再访问 → 同时产出UserModel.queryref("两个都发,与 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 只覆盖bodyfield,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/Suit;self::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一条implementsref、各带完整文本(\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)逐一 walkPhpTypePosition:primitive_type 无产出;name 文本不在伪类型表(self/static/parent/mixed/object/iterable/callable/void/null/false/true/never/array/int/float/string/bool)→ 该位置一条 references;qualified_name 取最后一个 \ 段(非伪)→ ref 在 qualified_name 位置;包装类型递归。于是 ?Logger → Logger;Mailer|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-L188 的 extractImport 钩子优先,walker 实现见 php.rs#L868-L993:
- include/require(+_once)(php.ts#L22-L34
phpStaticIncludePath):参数 =namedChild(0),parenthesized_expression解包一层;必须是string|encapsed_string且所有命名子都是string_content(任何插值/转义 → null)。静态 → import 节点名为路径 + 一条importsref,fromNodeId= nodeStack 顶(有文件级命名空间时是命名空间节点),位置取 include 节点。动态(require __DIR__ . '/x'、变量)→ 钩子 null → 落入if (extractImport) return守卫 → 什么都不产。消费方是resolveIncludePath(import-resolver.ts 中的路径形 imports ref:后缀/相对文件匹配,缺.php补上)。 - 单条
use(含use function/use const/别名):钩子取namespace_use_clause→ 其qualified_name(完整文本,不含别名)否则name(裸单段导入,如use Countable;)→ import 节点 + 通用importsref;随后 php 专属的emitPhpUseRefs/pushPhpUseRef:剥前导\,不再含\→ 返回(裸use Countable;只产通用 ref,无::ref);否则 ref 名 = 末段\换::(App\Contracts\Logger→App\Contracts::Logger),行/列取声明节点。 - 分组
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::Bref。嵌套Sub\E子句在新语法下是qualified_name子 → 整个子句跳过(§3 差异 #2)。多个 import 节点共享声明位置。 - 其他钩子 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})。因为每个 phpname节点都会匹配——常量读取、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_contenttrim 后文本;匹配简单名或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},referenceKindfunction_ref。
12. 框架提取器:留在 TS 侧,但钉住 walker 的输出契约
- laravelResolver(laravel.ts):detect
artisan/app/Http/Kernel.php。extract() 只对.php文件,对剥注释后的源码正则Route::METHOD(...)/Route::resource→route节点,ID 是字面量(不哈希)+ handler ref(Cls@method/Cls)。resolve() 消费Model::method(只有 fn-ref 字符串 callable / use ref 会产出::形状)与Controller@method。对 walker 的依赖仅是方法/类节点的名字与 kind。 - drupalResolver(drupal.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 逻辑是
cleanCommentMarkers的gm逐行剥离(^#\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 的五步门禁,全部已在仓库中留下可验证痕迹:
- 语法 bump 先行独立落地(wasm vendor +
=0.24.2pin +VENDORED_WASM_LANGS+ kernel-grammar-parity 的GRAMMAR_LANGUAGES += 'php'一个变更内),全套件绿,在任何 walker 存在之前。旧 vs 新 wasm 的 full-init dump diff 预期非空——每个 hunk 必须归入 §3 的类别,其他类别阻断 bump。 - Torture fixtures:仓库中实际存在 torture.php、TortureModule.module(drupal 扩展名路由 + hook 文档块)、TortureHtml.php(前置/交错 HTML +
<?=),CRLF 变体内存派生,外加一个故意语法错误的 defer fixture(真损坏语法如未闭合function f( {——不是 8.4 特性,那些在 v0.24.2 上解析干净)。 - 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 vsCODEGRAPH_KERNEL=0,scripts/dump-graph.mjs+ cmp)。 - 套件: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时跳过变失败。 DEFAULT_ROUTED += 'php'在上述全部完成之后(已落地,kernel/index.ts),changelog 搭既有 kernel 条目。Post-route 健全性检查:门禁仓库走 raw 路径,所以另需一个带artisan的 Laravel APP 与一个 Drupal module 做解码路径冒烟(drupal hook-ID 重构仍要命中——一个带 hook docblock 的.modulefixture)。
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->m → this->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::CONST、Cls::class、Cls::$prop、self::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 本身,而在它展示了一套"解释器双实现一致性迁移"的完整操作:
- 先升级依赖再移植逻辑,且把升级 diff 当作一等公民——PHP 的 bump 不中性,所以门禁从"期望零 diff"改成"枚举 + 分类 + ripple 可机械证明",避免团队在数百个 ripple hunk 上无谓消耗;
- 把"保留哪些 bug"写成规格——垃圾 ref 的精确字节、被丢弃的多继承基类、命名空间文件的常量 target 丢弃,全部以 PRESERVE 标注进入 walker 注释与 fixture,让"bug-for-bug"从口号变成可测试断言;
- 门禁分层:torture fixture(分支级)→ 全仓库扫描(文件级 0 差异)→ full-init dump 字节比对(图级)→
DEFAULT_ROUTED路由开关(发布级),每层失败信号不同(fixture 失败 = 分支错;扫描 deferral 超阈 = walker 或语法问题;dump 不一致 = 顺序/ID/编码问题); - TS 侧框架提取器不迁移但契约化——walker 的节点 ID 与行号公式成为跨实现 API,drupal 的 ID 重构是契约中最脆的一环,被显式点名。
对要在自己的项目里做"旧实现 → 高性能新实现"迁移的读者,这套 checklist 可以直接当模板:先写满"每个分支做什么/不做什么"的表格(含 file:line 锚点),再写"语法/依赖升级差异分类",最后写"门禁与 fixture 清单"——三者齐备,移植期的每一行 diff 都有归属。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00