Fourth and final R7b batch-2 port, checklist-first recipe (docs/design/php-kernel-port-checklist.md). Grammar bump first, validated standalone with the diff ENUMERATED + CLASSIFIED (unlike rust/ruby the php bump is NOT graph-neutral): tree-sitter-php ^0.22 (tree-sitter-wasms, 2023) → v0.24.2, the full HTML-interleaving `php` variant (the walker calls LANGUAGE_PHP, never PHP_ONLY) — crate pinned =0.24.2, wasm built from tag 5b5627f's checked-in php/src/parser.c + scanner.c + shared common/scanner.h (all sha-matched against the crates.io tarball, ABI 14→15). Old-vs-new full-init diffs decompose completely into: (1) the anonymous_class wrapper shape (anon-class nodes/methods re-shape — 2,532 rows), (2) grouped nested-clause skip (absent in the gate repos, fixture-pinned), (3) 32 formerly-erroring files parsing clean (monolog Level.php, symfony Request/Response with 8.4 property hooks), (4) a survey-missed category found at gate time: the 8.4 parenthesis-free `new X()->m()` chaining misparse fix (86 garbage instantiates refs disappear, precision-positive), plus resolution RIPPLE proven mechanically (every remaining ref-table flip pairs 1:1 with a resolved edge on the opposite side; node rows byte-stable outside 1/3/4). Walker (java.rs chassis + the php specifics) preserves bug-for-bug: the visitNode hook (const_declaration at ANY scope → bare `constant` nodes, values never walked; trait-use → implements refs WITH filePath via the ruby port's REF_FLAG_FILE_PATH wire slot), FIRST-namespace whole-file scoping (braced namespaces scope nothing; namespaced files DROP top-level const value-ref targets), the import trio (single/aliased/grouped incl. the nested-clause skip, include/require static-literal-only, `Foo\Bar::Baz` use refs), the call-encoding zoo (DOT-joined scoped calls, `this->prop.m` #1251 encoding, `Cls::factory().m` fluent with inner args dropped, nullsafe `?->` emitting nothing, unsuppressed literal receivers), interface multi-extends first-base-only drop, anon-class methods as file-level functions (top) or vanishing (in-body), property type-hints emitting no field refs, the final-modifier-as-type signature quirk, HOF-gated string callables (skipGate) + array callables, and the `name`-node value-ref reader. Gates: sweeps 0-diff monolog 217/217, laravel-framework 3007/3008, symfony 10726/10737 (13,950 files byte-parity; 12 deferrals = exactly the predicted genuinely-broken fixtures, ≈0–0.1%); full-init dumps byte-identical ×3 (16.1k/354.2k/702.8k lines); kernel-php-parity suite (torture + drupal .module + leading-HTML fixtures, CRLF variants, wire-flag pin, defer) + php grammar-parity row; full suite 2,622 green ×2 under CODEGRAPH_KERNEL_EXPECT=1. DEFAULT_ROUTED += php (13 languages). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
63 KiB
PHP kernel port (R7b) — the bug-for-bug checklist
Status: PORT COMPLETE (2026-07-20) — walker codegraph-kernel/src/php.rs,
all gates passed (grammar bump validated standalone with the diff enumerated +
classified — see §Grammar-bump deltas incl. the bump-gate-found category 4 and
the ripple-proof note; parity sweeps 0-diff monolog 217/217 /
laravel-framework 3007/3008 / symfony 10726/10737 with only the predicted
broken-fixture deferrals; full-init dump gates byte-identical ×3;
kernel-php-parity suite; DEFAULT_ROUTED += php — 13 languages). Trait-use
implements refs carry filePath via the v2 REF_FLAG_FILE_PATH wire slot
(shipped with the ruby port). Survey basis:
every TS-side branch a php-routed file exercises, with file:line anchors as of
f1ca991 (HEAD at survey time, clean main). Every grammar-shape claim below
was probed against both the current production wasm (tree-sitter-wasms
0.1.13 build of tree-sitter-php ^0.22, ABI 14) and a fresh v0.24.2 build
(probe scripts + dumps in the session scratchpad svy-php/ — see §Probe
artifacts), not assumed. Read WITH docs/design/rust-kernel-migration-plan.md
(§0a recipe, §2 boundary, §5 gates) and the two format precedents
(rust-lang-kernel-port-checklist.md, ccpp-kernel-port-checklist.md).
Blocking findings: none. Two eyes-open notes, neither blocking: (1) the
grammar bump is NOT graph-neutral — unlike rust, the old→new wasm diff has
three known behavior-changing deltas (anonymous classes, one grouped-import
clause shape, enum-const files parsing clean), so the bump's standalone gate is
"enumerate + classify the diff", not "expect zero" (§Grammar-bump deltas); (2)
Laravel/Drupal-detected repos force the decoded path via framework extract()
hooks, but none of the three gate repos triggers detection, so raw-path sweeps
are representative (§Architecture decisions #2).
Grammar prep (NOT staged — land FIRST, before any walker exists)
php is not in VENDORED_WASM_LANGS (grammars.ts:291) — production loads
require.resolve('tree-sitter-wasms/out/tree-sitter-php.wasm')
(grammars.ts:307-312; mapping php: 'tree-sitter-php.wasm' at grammars.ts:32),
a 2023-era ABI-14 build of npm tree-sitter-php ^0.22 (sha256 55bb617b…,
812,594 bytes).
- Variant: the full
phpgrammar, NOTphp_only. Probed: the current wasm parses a mixed HTML+PHP file with root-leveltext/php_tag/text_interpolationnodes and no errors — that is thephp/grammar of the two-grammar repo. The bump MUST keep this variant and the kernel walker MUST calltree_sitter_php::LANGUAGE_PHP(the crate also exportsLANGUAGE_PHP_ONLY— wrong one; a php_only build ERRORs on any leading HTML, which is a routine Drupal/legacy shape). - Version: crate
tree-sitter-php0.24.2 (crates.io max_stable) = repo tagv0.24.2= commit5b5627faaa290d89eb3d01b9bf47c3bb9e797dea("fix: publishing, 0.24.2"). sha256-matched tag ↔ crate tarball:php/src/parser.c59ad8e5e4fde3fe60687a488ab8420612840cc966b83739af1b3a4317ed27ec6php/src/scanner.c58c92cafe4ebda509c3ad3864fa6fc0e9877bbac26e17a03d23ea2101c291ad5— a thin wrapper: the real external scanner is the SHAREDcommon/scanner.h(de8eb36bc8f517ab9f3eaf82e3825d7b3e11b62e9471545f4c906100cfce0e07),#included by both variants. External scanner: YES (heredoc/nowdoc, encapsed strings,?>/text interleaving live there) — the crate build compiles it automatically; the wasm build picks it up fromsrc/.
- Build (from the tag's CHECKED-IN parser.c — never
tree-sitter generate):(brew emcc present; survey artifact: ABI 15, 1,058,082 bytes, sha256git clone --depth 1 --branch v0.24.2 https://github.com/tree-sitter/tree-sitter-php cd tree-sitter-php/php # the variant subdir — NOT the repo root npx tree-sitter-cli@0.25.10 build --wasm -o tree-sitter-php.wasm .6545a9a110bc878e26ed329950147e190c83da038bb17e999de646fe6c4d6c82, left at scratchpadsvy-php/tree-sitter-php.wasm.) - Staging plan: vendor to
src/extraction/wasm/tree-sitter-php.wasm, add'php'toVENDORED_WASM_LANGS(grammars.ts:291), pintree-sitter-php = "=0.24.2"in codegraph-kernel/Cargo.toml (crate + wasm move TOGETHER), add'php'toGRAMMAR_LANGUAGESin__tests__/kernel-grammar-parity.test.ts:39and togrammar_forincodegraph-kernel/src/langs.rs(+ theLANGUAGESconst). MIT license, same family as the other vendored grammars.copy-assetsalready globssrc/extraction/wasm/*.wasm. - Bump lands FIRST with the full suite green and the old-vs-new full-init dump diff on the gate repos enumerated + classified (see §Gates — for php this diff is expected NON-empty; every hunk must fall into a §Grammar-bump deltas category).
Error incidence (probed, full php-routed file sets, 1 MiB skip applied)
| Repo | files | OLD (ABI-14 ^0.22) | NEW (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%) — a deliberately-broken test fixture |
| symfony | 10,736 | 40 (0.37%) | 11 (0.10%) — broken/8.4+ fixtures |
Both arms sit inside the ts/java/py/go norm (0–0.42%). Deferral guard stays
at the default --max-deferral 0.1 — the c/cpp 0.5 exemption does NOT apply;
double-digit deferral on a php sweep means a broken walker. Old-grammar-only
failures (fixed by the bump, probed construct-by-construct): const inside an
enum body, property hooks (8.4), asymmetric visibility (8.4). Everything else
(8.0–8.3: enums, readonly, promotion, DNF/intersection types, first-class
callables, nullsafe, match, attributes, named args, typed class consts) parses
clean on BOTH.
Grammar-bump deltas (old → v0.24.2), every one classified
Full-tree diff on a clean-parsing torture file = 278 lines, all accounted for:
Behavior-changing (the bump gate must show exactly these, nothing else):
- Anonymous classes get a wrapper node. OLD:
new class … { }putsbase_clause/class_interface_clause/declaration_listDIRECTLY underobject_creation_expression; NEW nests them in ananonymous_classchild (body:field on the list). Consequences (branches:findAnonymousClassBodytree-sitter.ts:4815 andextractInstantiation:4610):- OLD behavior:
declaration_listis a direct child → anon-CLASS node<T$anon@line>+extendsref + method nodes (extractAnonymousClass :4837). NEW behavior (what the WALKER implements):findAnonymousClassBodyfinds nothing → no anon class node, no extends ref; the walker descends instead — at top level the innermethod_declarations hit the methodTypes branch, failisInsideClassLikeNode, and extract as file-levelfunctionnodes; inside a body,visitForCallsAndStructurehas no methodTypes branch, so anon-class methods vanish and their inner calls attribute to the enclosing function. - extractInstantiation's ctor (
namedChild(0), no field): OLD = thebase_clause(ref textextends B) ordeclaration_list; NEW = the wholeanonymous_class→ className = the ENTIRE class text run through the<-strip + lastIndexOf('.'/'::') suffix logic (:4669-4686) — garbage either way, differently-shaped garbage. Reproduce the NEW shape exactly; pin both in the fixture.
- OLD behavior:
- Grouped-import nested clause drops. OLD
use A\{Sub\Deep}clause isnamespace_use_group_clause > namespace_name > name…— the inline branch (tree-sitter.ts:3322-3347) findsnamespace_nameand emits import node + ref forA\Sub(wrong, but old behavior). NEW clause isnamespace_use_clause > qualified_name— the branch'sfind(c => c.type === 'name')finds nothing → that clause is silently skipped (no import node, no ref). Simple (Mailer) and aliased (Cache as CacheAlias— childrenname,as,alias: name; the find returns the FIRSTname, i.e. the source name) group members behave identically on both. The 3329 predicate already accepts both clause type names. - Old-grammar parse-error files now parse. Enum-const files (monolog's
Level.phpclass) go from mangled/error extraction to clean — node/edge diffs on such files are the bump working as intended. - (Found at bump-gate time, survey-missed.) PHP 8.4 parenthesis-free
new X()->m()chaining misparse fix. OLD parses the whole chain as ONEobject_creation_expressionwith NO error flag (which is why the survey's error matrix missed it) → extractInstantiation emitted a garbageX()->m-shaped instantiates ref; NEW parses correctly asmember_call_expression(object_creation_expression(X), m)→ properinstantiates X+ call refs. 86 such refs across symfony; probe:probe-newchain.mjs. Precision-positive, same nature as ruby's&.!=.
Bump-gate ripple note (measured 2026-07-20): beyond the four categories, the full-init dump diff carries RESOLUTION ripple — refs that flip between the parked unresolved_refs table and resolved edges because the graph gained symbols (category 3 recovering Request.php/Response.php re-resolves refs in hundreds of OTHER files). Ripple is provable mechanically: every side-only parked ref outside category-1/3/4 files pairs 1:1 with a resolved edge (same source/refName/line/col) on the opposite side, and node rows are byte-stable outside those files (ripple-proof.mjs — monolog 3/0 unpaired, framework 26/0, symfony 2,132/86-unpaired-all-category-4). Don't re-litigate ripple hunks per-file.
Inert (verified against every consuming branch):
qualified_nameinternals:namespace_name_as_prefixwrapper →prefix:-fielded children. Every consumer readsgetNodeTextof the wholequalified_nameorfind(type === 'name'/'namespace_name')on OTHER nodes — no TS code referencesnamespace_name_as_prefix(grepped). Text identical.namespace_use_clausegains atype:field (use function/use const— the keyword moves inside the clause); the hook'sfind('namespace_use_clause')find('qualified_name')path is shape-independent. Same result.
- Single aliased
use X as Y:namespace_aliasing_clause→ flatas+alias: name. Hook and emitPhpUseRefs read thequalified_name(source name) only. Same. property_element:variable_namegains aname:field;property_initializerwrapper →= (anon)+default_value:field. The extractField php branch finds by TYPE (variable_name, then itsnamechild) and the property_declaration-level type scan excludes only modifier + property_element types — direct children unchanged. Same.anonymous_function_creation_expression→anonymous_function: NO TS code names either type (closures aren't extracted, see §Closures). Inert.primitive_typebecomes a leaf (anon keyword children likevoid,mixed,iterable,falsedropped). All reads are node-type + text. Inert.text_interpolation's?>token → namedphp_end_tagchild. No branch touches either; visitNode recursion over it is a no-op. Inert.namespace_definitiongainsname:field onnamespace_name— extractPackage finds by type. Inert.new static()/new self()/new parent(): both grammars produceobject_creation_expression > name; OLD prints an anon keyword child undername, NEW is a leaf — text identical (static/self/parent). Inert (the instantiates ref is that literal text — see §extractInstantiation).enum_casegainsvalue:field — never read (extractEnumMembers returns after the name-field path). Inert.- attributes
#[…]: identicalattributes: attribute_list > attribute_group > attributeshape on both; extraction ignores them entirely (§Attributes).
Architecture decisions
- No preParse.
phpExtractorhas nopreParsehook (languages/php.ts — whole file, no such key), sopreParsedSource(kernel/index.ts:82) is a no-op for php — both arms parse raw bytes. Nothing to hoist. - Laravel/Drupal repos take the DECODED path; the three gate repos do NOT.
laravelResolver(resolution/frameworks/laravel.ts:38,languages:['php'], detect =artisanfile orapp/Http/Kernel.phpexists) anddrupalResolver(drupal.ts:296,languages:['php','yaml'], detect = composer.jsondrupal/*deps/name/type, else.info.yml+ drupal file) BOTH haveextract()hooks, and parse-worker.ts:93-99 forces any language with an applicable frameworkextract()onto the decodedextractFromSourcepath. monolog / laravel-framework / symfony trip NEITHER detector (noartisan, no drupal composer manifest) → their php files ride the raw-buffers transport. Don't conclude the raw path is broken from a Laravel APP repo, and don't conclude framework hooks are dead from the gate repos. - Framework extractors themselves need NO port (regex-over-raw-source TS,
run in extractFromSource:6736-6758 after either arm) — but they pin parts of
the walker's output contract (§Frameworks): drupal reconstructs extraction
node IDs with
generateNodeId(filePath,'function',name,line). - One walker module (suggest
codegraph-kernel/src/php.rs), registered inlangs.rs(grammar_for→tree_sitter_php::LANGUAGE_PHP.into(),LANGUAGESconst += "php"); per-filehas_error()→defer:like every walker. Skeleton mapping: java.rs is the closest crib (class-like scope stack, fields, enums, imports-with-hook, static-member refs, decorators no-op, value refs) — php adds the visitNode-hook branches, the package-namespace capture (java.rs has the sameextractFilePackagemechanic), the php import trio, and the php type-ref walker; rustlang.rs is the crib for hook-suppressed import fallbacks and thenode_idsdedupe pattern. - Extensions:
.php, and the Drupal set.module/.install/.theme/.incall map tophpat detectLanguage (grammars.ts:92-97) — no content sniffing, no dialect. Sweeps and fixtures must include a non-.phpextension file. MAX_FILE_SIZE (1 MiB, extraction/index.ts:132) and generated-file skips are orchestrator/TS-side and shared. - No POST_PASSES entry (kernel/index.ts:67 — none for php), so
tryKernelExtractRawstays eligible.
Extractor config (languages/php.ts — 189 lines, read it whole)
Types: functionTypes=[function_definition];
classTypes=[class_declaration, trait_declaration] with
classifyClassNode → 'trait' for trait_declaration (php.ts:86) — a trait is
kind trait via extractClass(node,'trait') (tree-sitter.ts:1014-1015);
methodTypes=[method_declaration]; interfaceTypes=[interface_declaration]
(kind interface — no interfaceKind override); structTypes=[];
enumTypes=[enum_declaration]; enumMemberTypes=[enum_case];
typeAliasTypes=[]; importTypes=[namespace_use_declaration,
include_expression, include_once_expression, require_expression,
require_once_expression]; callTypes=[function_call_expression,
member_call_expression, scoped_call_expression] — NOT
nullsafe_member_call_expression (see §extractCall);
variableTypes=[const_declaration] (DEAD for dispatch — the visitNode hook
consumes const_declaration first, see §visitNode hook);
fieldTypes=[property_declaration]. nameField=name, bodyField=body,
paramsField=parameters, returnField=return_type.
Hooks PRESENT (port each exactly):
- getReturnType = extractPhpReturnType (php.ts:50) —
return_typefield;optional_typeunwraps tonamedChild(0) ?? rt; thenprimitive_type→ undefined. nameNode =named_type?namedChild(0) ?? rt: rt; text = trim + strip leading\; empty → undefined; last = last\-segment; lowercase ∈ {self, static, this, $this} → the marker'self'(chained-call #608 resolves it to the declaring class); lowercase ∈ PHP_NON_CLASS_RETURN (php.ts:37 — array string int integer float double bool boolean void mixed never null false true object callable iterable resource) → undefined; must match/^[A-Za-z_]\w*$/else undefined (killsA|Bunions — union_type is neither optional nor named_type, so nameNode = the union node, text =A|B, regex fails). PROBED shapes:: selfand: staticare named_type > name on v0.24.2 → marker'self'LIVE for both;: void/: mixed/: stringare primitive_type → undefined;: ?Foo→ optional_type > named_type →Foo;: \App\Models\User→ qualified_name (not named_type) → nameNode = rt → text strips lead\→ last segmentUser. - classifyClassNode (php.ts:86) — trait_declaration → 'trait', else 'class'.
- getVisibility (php.ts:89) — scan ALL children (
child(i), anonymous included) forvisibility_modifier; its text exactlypublic/private/protected→ that; no modifier →'public'(php default). Called for functions, methods, classes, enums, structs(n/a), properties via extractField. Notefinal_modifier/abstract_modifier/readonly_modifierchildren are skipped by type. - isStatic (php.ts:101) — any child of type
static_modifier→ true, else false. - visitNode hook (php.ts:108) — see §visitNode hook. Fires for EVERY node visited by the main walker (tree-sitter.ts:943-953), NOT by visitFunctionBody's walker.
- packageTypes=[
namespace_definition] + extractPackage (php.ts:149-156) — see §Namespace capture. - extractImport (php.ts:157-188) — see §Imports.
Hooks ABSENT (the walker must NOT do these): preParse, getSignature (php
function/method nodes have NO signature — undefined), isAsync (undefined,
not false), isConst, isExported (undefined on every php node except the
file node's literal false), resolveName, recoverMangledName,
isMisparsedFunction, resolveBody, getReceiverType (methods only via
class-like scope; receiverType is always undefined → no
composeReceiverQualifiedName, no owner-contains fallback at
tree-sitter.ts:1799), classifyMethodNode, extractPropertyName,
propertyTypes, extraClassNodeTypes, extractModifiers,
synthesizeMembers, extractBareCall, skipBodilessClass (a bodiless
class_declaration still mints a node — doesn't occur in valid php),
methodsAreTopLevel, interfaceKind.
tree-sitter.ts branches (anchors as of f1ca991)
visitNode dispatch — what each php node hits
| Node | Branch | Behavior |
|---|---|---|
| any node, first | visitNode hook, tree-sitter.ts:943-953 | php hook consumes const_declaration + use_declaration (§visitNode hook); on true: scanFnRefSubtree(node,0) then return (no descent) |
text / php_tag / text_interpolation (+ its php_end_tag) |
no branch | recursed, nothing extracted. Positions of later nodes are absolute file coordinates — a file with leading HTML has its first symbol at the real (post-HTML) row |
namespace_definition |
NOT dispatched in visitNode | consumed once by extractFilePackage (:1397, root's direct children scan) BEFORE the walk; the walk then recurses through it finding nothing (namespace_name/name have no branches). Braced form: extractPackage returns null (body check) → no namespace node, contents index at file scope, bare QNs — probed identical both grammars (body: compound_statement) |
function_definition (top level / inside namespace) |
functionTypes:994 → extractFunction:1517 | never methodTypes (php methodTypes lacks function_definition) → always extractFunction at top level |
class_declaration |
classTypes:1005 → classify → extractClass:1679 | kind class. trait_declaration → classify 'trait' → extractClass(node,'trait'):1015 → kind trait |
interface_declaration |
interfaceTypes:1054 → extractInterface:1834 | kind interface; body walked with interface pushed → method_declarations become methods (bodiless — ; — still nodes, no body walk) |
enum_declaration |
enumTypes:1064 → extractEnum:1914 | body: field = enum_declaration_list; the backing type (: string, an unfielded primitive_type child) is never read; class_interface_clause child → implements refs via extractInheritance; enum_case children → extractEnumMembers; method_declaration/const_declaration/use_declaration children → visitNode (methods extract, consts + trait-uses via the hook) |
property_declaration |
fieldTypes:1084 (gated isInsideClassLikeNode) → extractField:2046 |
§Fields. Outside a class-like (invalid php) → falls through, children recursed |
const_declaration |
visitNode hook (BEFORE the ladder) | §visitNode hook — the variableTypes:1098 branch is UNREACHABLE for php; extractVariable is never called |
use_declaration (trait use, inside class/trait/enum body) |
visitNode hook | §visitNode hook |
namespace_use_declaration, include/require ×4 |
importTypes:1209 → extractImport:3170 | §Imports |
function_call_expression / member_call_expression / scoped_call_expression |
callTypes:1248 → extractCall:3684 | §extractCall. Top-level calls attribute to the FILE node (nodeStack=[file]) |
nullsafe_member_call_expression |
no branch | recursed — ?-> calls emit NOTHING (#1251 follow-up, deliberately unshipped; pin CURRENT behavior). Inner argument calls still extract via recursion |
object_creation_expression |
INSTANTIATION_KINDS:354(object_creation_expression), visitNode:1255 + body walker:5145 |
extractInstantiation + findAnonymousClassBody (§extractInstantiation) |
expression_statement, echo_statement, global_declaration, function_static_declaration, match_expression, anonymous_function, arrow_function, attribute machinery, … |
no branch | recursed. Calls/instantiations inside top-level closures attribute to the file node |
visitNode hook (php.ts:108-144) — const + trait-use
Runs from tree-sitter.ts:943 with the ExtractorContext (:1465). Two branches:
const_declaration(ANY scope — top level, class, interface, trait, enum): for each namedChild of typeconst_element: nameNode = its namedChildrenfind(type==='name')(the FIRSTname— which IS the const name; the value ofconst A = OTHER_CONSTis also anamenode but comes second); skip if none;ctx.createNode('constant', name, elem, {})— position = the const_element, one node per element (const A = 1, B = 2→ twoconstantnodes), extra = {} so no docstring, no signature, no visibility, no isStatic — afinal public const int X = 5typed const carries none of that. Returns true → hook-consumed →scanFnRefSubtree(node,0)(capture-only; php's dispatch isarguments-only so const initializers essentially never capture) → no descent: const VALUES are never walked (no calls/instantiates from const initializers). Contains edge from nodeStack top (file/class/interface/trait/ enum). captureValueRefScope runs inside createNode (§Value refs).use_declaration(trait use inside a class-like body): names = namedChildren filteredtype === 'name' || type === 'qualified_name'— the used trait names ONLY (theuse_listconflict block{ A::g insteadof B; B::g as protected h; }is typeuse_list, filtered out; its inner class_constant_access/name nodes are not direct children — probed). parentId = nodeStack top (the class); if none, nothing. Per name: unresolved ref {fromNodeId: parentId, referenceName: trait text (qualified_name keeps fullFoo\Bartext), referenceKind:implements, line/column of the use_declaration node (same position for every name inuse A, B;)}. Returns true → scanFnRefSubtree → no descent (insteadof/as clauses never extracted — no aliased-method nodes, no conflict-resolution edges).
Namespace capture — extractFilePackage (:1397) + extractPackage (php.ts:150)
Before the walk: scan the ROOT's direct namedChildren for the FIRST
namespace_definition (break at :1407 — a file with multiple namespaces
scopes everything under the first). extractPackage: nsName = namedChildren
find(type==='namespace_name'); hasBody = any namedChild of type
compound_statement | declaration_list; !nsName || hasBody → null (braced
namespaces make NO node and NO scoping); else the namespace_name text
(App\Services). createNode('namespace', 'App\Services', the
namespace_definition node) → node #2 after the file node, regardless of
where the declaration sits (e.g. after declare(strict_types=1)); pushed on
the nodeStack for the WHOLE walk → every top-level symbol's qualifiedName =
App\Services::Name (buildQualifiedName :1447 joins stack names with ::;
namespacePrefix is always empty outside C/C++) — this is what
pushPhpUseRef's Foo\Bar::Baz refs resolve against. Methods:
App\Services::UserService::run.
Node creation, IDs, order
- createNode (:1308): id =
generateNodeId(filePath, kind, name, startRow+1)=`${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex.slice(0,32)}`(tree-sitter-helpers.ts:18). File node id = literalfile:${filePath}(:509), name = basename, qualifiedName = filePath, endLine =source.split('\n').length, isExported false. Dedupe/self-checks compare ID STRINGS (node_idsvec pattern). - endLine extension via resolveBody (:1329) — no hook → no-op for php.
- contains edge from nodeStack top for every created node (:1363).
- A declaration with attributes STARTS at the attribute —
#[Registry]\n class UserServicemints the class node at the#[row (node position = declaration node = attribute_list start). Affects generateNodeId's line AND drupal's function-id reconstruction (§Frameworks). - Emission order = TS walk order: file node → namespace node (if any) → source order (per construct: node + contains edge → its refs in extractor order) → fn-ref refs (flushFnRefCandidates :538) → value-ref EDGES (flushValueRefs :539). Store/harness are rowid-order-sensitive.
extractFunction / extractMethod (:1517 / :1737)
- extractFunction: no getReceiverType → never diverts (:1522 no-op). Name via
extractName (:90) → nameField
name.<anonymous>never occurs for function_definition (grammar requires a name). Node: docstring (§Docstrings), signature undefined, visibility (hook —'public'for a bare function), isExported undefined, isAsync undefined, isStatic false (hook returns false when no static_modifier), returnType (hook). Then extractTypeAnnotations (§Type refs), extractDecoratorsFor (§Attributes — no-op), push, walkbodyfield (compound_statement) via visitFunctionBody, pop. - extractMethod (method_declaration inside class/trait/interface/enum): gate
:1747 passes via isInsideClassLikeNode (:1486 — parent kind ∈ class, struct,
interface, trait, enum, module). Same extras as function (visibility from
modifiers, isStatic real). receiverType undefined → no QN override, no
:1799 owner-edge. Bodiless method (interface/abstract):
bodyfield missing → no body walk, node still minted. - Nested named function inside a body (
function inner() {}in a method): visitFunctionBody:5245 → functionTypes + named → extractFunction → afunctionnode contained by the enclosing method. - Body-level class/interface/enum/trait declarations (the polyfill idiom
if (!class_exists('X')) { class X {} }): visitForCallsAndStructure :5255-5275 dispatches classTypes (incl. the trait classification) / enumTypes / interfaceTypes → full extraction, contained by the enclosing function. NOTE the body walker does NOT run the extractor's visitNode hook — but extractClass's own body walk uses visitNode, so consts/trait-uses INSIDE a body-level class still extract via the hook. - Closures (
anonymous_function, renamed fromanonymous_function_creation_expression— both untyped in TS) andarrow_function: no nodes ever — not in functionTypes; body walker recurses through them so their calls attribute to the ENCLOSING function/method/file.scanFnRefSubtree's halt list (:606-612) includesarrow_function(halts scans at php arrow fns) but NOTanonymous_function(scan descends into closures — capture-only). - First-class callable
foo(...)/$x->m(...)/Cls::m(...): an ordinary call node with avariadic_placeholderargument → plaincallsref via extractCall (the function-ref spec deliberately leans on this — see function-ref.ts:361 comment).
extractClass / extractInterface / extractEnum for php
- extractClass (:1679): resolvedBody =
bodyfield (declaration_list); no skipBodilessClass. Node kind class/trait: docstring, visibility (hook → bare class = 'public'), isExported undefined. extractInheritance (§below), extractCsharpPrimaryCtorParamRefs (no-op — needsparameter_listchild type, php has none), extractDecoratorsFor (no-op), push, visit BODY namedChildren (hook first → consts/trait-uses; method_declaration → extractMethod; property_declaration → extractField; nested class_declaration → extractClass), no synthesizeMembers, pop. - extractInterface (:1834): kind
interface; docstring, isExported undefined (NO visibility read — extractInterface never calls getVisibility); extractInheritance sees the interface'sbase_clause; body children visited with the interface pushed (methods, consts via hook). - extractEnum (:1914): body required (
bodyfield). docstring, visibility ('public'), isExported undefined. extractInheritance → class_interface_clause → implements. Body loop:enum_case∈ enumMemberTypes → extractEnumMembers (:1958):namefield path → ONEenum_membernode fromgetChildByField(node,'name'), positioned at the enum_case, then return — backed-case values (= 'H') never walked. Other children → visitNode (methods/consts/use).
Fields — extractField php branch (:2077-2104)
property_declaration inside a class-like: docstring = preceding comment of the
DECLARATION; visibility (hook); isStatic (hook). Java/C# variable_declarator
finds miss → php branch: propElements = namedChildren of type
property_element (≥1 in any valid property_declaration). typeNode = FIRST
namedChild NOT of type {visibility_modifier, static_modifier,
readonly_modifier, property_element, var_modifier} — i.e. the type node
(primitive_type / named_type / optional_type / union_type / …) when present;
QUIRK: final_modifier/abstract_modifier are NOT excluded — a
final public Foo $x (php 8.4 final props; parses on 0.24.2) would take the
final_modifier as the "type" (typeText = final). typeText = raw node text
(?Logger, iterable|CacheAlias). Per element: varName = namedChildren
find(type==='variable_name'); nameNode = varName's find(type==='name');
name = name (NO $); signature = typeText ? `${typeText} $${name}` : `$${name}` (the $ is re-added in the signature only); one field node
per element positioned at the property_element (multi: private ?Logger $logger, $fallback; → two nodes, same typeText), THEN RETURN — the php
branch skips extractDecoratorsFor AND extractTypeAnnotations (both are only
on the declarators path :2118-2141) → property type-hints emit NO
references from the field node (the class's METHODS carry php type refs;
properties don't). var $legacy; → var_modifier excluded → no type →
signature $legacy. Untyped default (default_value) never walked — no refs
from initializers. Promoted constructor params (property_promotion_parameter)
are NOT fields — no node anywhere (§Type refs covers their type hints).
Value-const kind upgrade (:2058) is java/csharp-gated — php fields stay field.
Imports (:3170-3356 + :3508-3574)
extractImport, hook-first (:3176). Four php shapes:
- include/require (+_once) (php.ts:163): phpStaticIncludePath — arg =
namedChild(0);
parenthesized_expressionunwraps one level; must bestring|encapsed_string; ALL namedChildren must bestring_content(any interpolation/escape → null); content = the string_content text. Static →{moduleName: path text, signature: trimmed full expression text}→ import node named the PATH + (no handledRefs) animportsref {fromNodeId: nodeStack top — the NAMESPACE node when a file-level namespace exists, else the file node (validated on the built extractor:from=namespace:…), referenceName: the path, line/col of the include node} (:3183-3194). Import NODES likewise get their contains edge from the namespace and a namespace-prefixed qualifiedName (App::App\Contracts\Logger). Dynamic (require __DIR__ . '/x', variables) → hook null → falls THROUGH the php grouped branch (include nodes never match it) →if (this.extractor.extractImport) return;(:3350) → nothing emitted. Consumed by resolveIncludePath (import-resolver.ts:682-758) — suffix/relative file matching,.phpappended if missing. - Single
use(incl.use function/use const/aliased): hook findsnamespace_use_clause→ itsqualified_name(full text, e.g.App\Contracts\Logger— alias NOT included) else itsname(bare single-segment import, e.g.use Countable;) → import node named that + the genericimportsref (same shape as includes). THEN the php-only :3224-3227 adds emitPhpUseRefs (:3515): clause → qualified_name ?? name → pushPhpUseRef (:3563): strip leading\; no\left → RETURN (bareuse Countable;emits ONLY the generic ref, no::ref); else ref {fromNodeId = the same nodeStack top (namespace-or-file, per #1), referenceName:`${prefix}::${leaf}`(LAST\→::, e.g.App\Contracts::Logger), referenceKind:imports, line/col of the declaration node}.use function App\Helpers\format_id→App\Helpers::format_id(function imports ride the same path). - Grouped
use A\{B, C as D, Sub\E}: hook sees namespace_name + namespace_use_group → returns null (php.ts:171) → inline branch :3322-3347: prefix = namespace_name text; clauses = group's namedChildren of typenamespace_use_group_clause|namespace_use_clause(v0.24.2: namespace_use_clause); per clause: nsName = clause'sfind('namespace_name')(v0.24.2: never present) → name = nsName ? itsfind('name'): clause'sfind('name')— FIRSTname= the SOURCE name (aliases skipped); found → fullPath =`${prefix}\\${name}`→ import node named fullPath (positioned at the whole DECLARATION, signature = full text) + pushPhpUseRef(fullPath) →A::Brefs. NestedSub\Eclause: qualified_name child → find('name') misses → clause SKIPPED entirely (§Grammar-bump deltas #2). Multiple import NODES share the declaration's position → same-(kind,name-differs) but same-line ids;use A\{B, B}would collide — id-string dedupe territory. - Any other hook-null case (malformed): :3350 → nothing (no generic fallback).
QUALIFIED_IMPORT (flushFnRefCandidates :665) admits \-separated import refs —
php use refs DO feed the fn-ref gate their last segment (unlike rust's
:: paths): App\Contracts::Logger matches (./\ class) → contributes
Logger… CAREFUL: the ref text contains BOTH \ and :: — the regex
^[A-Za-z_$][A-Za-z0-9_$.\\]*[.\\]([A-Za-z_$][A-Za-z0-9_$]*)$ REJECTS :
characters entirely → App\Contracts::Logger does NOT match → contributes
nothing. The include-path refs (lib/plain.php) contain / → also rejected.
Net: only bare single-segment use X; refs (SIMPLE_NAME) reach
importedNames — the php fn-ref gate is effectively "defined in this file ∪
bare use imports ∪ skipGate candidates". Verify against the fixture.
extractCall (:3684) — the php paths
php never hits the vbnet/erlang/ruby/arkts branches. Entry: nameField =
name field, objectField = object ?? scope (:4137-4138).
Branch A (:4140) — member_call_expression / scoped_call_expression
(both have name + object/scope):
- php fluent static-factory (:4155-4173): objectField.type ===
scoped_call_expression(i.e.Cls::factory(...)->method()): innerScope/innerName = the inner call's scope/name fields → calleeName =`${scopeText}::${nameText}().${methodName}`(inner ARGS dropped —UserModel::query().where); either missing → bare methodName. Emit + RETURN. (Inner scoped_call is ALSO visited by the walker's recursion →UserModel.queryref too — both emitted, like rust chains.) Consumed by the resolution chain matcher (().marker); scope text can beself/static/qualified — emitted verbatim (self::make().x). - Java this-field unwrap (:4203) —
field_accessonly, never php. receiverName = raw objectField text with ONE leading$stripped (:4215replace(/^\$/,'')):$x->m()→ object variable_name$x→x→ calleex.m(feeds local-receiver inference #1108 / typed-param #1125 — resolution-side, name-matcher.ts:1210-1217 php patterns).$this->m()→this∈ SKIP_RECEIVERS (:4219 {self, this, cls, super, parent, static}) → barem.- #1251/#1220 property receiver
$this->prop->m()→ object = member_access_expression, raw text$this->prop→this->prop→ calleethis->prop.m. The ENTIRE #1251 machinery is RESOLUTION-side (name-matcher.ts:1333-1340 stripsthis->, phpPropertyTypePatterns :1418-1425 — modifier-prefixed typed property/promoted param OR$this->prop = new Foo(); the hardened SHADOWING GUARD: property-shaped patterns ONLY, so a plain$proplocal/param elsewhere can never type the property; second chance inferPhpAssignedPropertyType :1438 follows$this->prop = $var; matchMethodCall :1533-1549 routes^(this->\w+)\.(\w+)$EXCLUSIVELY through declared-type inference — unresolvable stays unlinked, never name-matched). Extraction's ONLY job: the exactthis->prop.mencoding + line/col. - Deeper
$this->a->b->m()→this->a->b.m(resolver won't match — stays unresolved).$obj->prop->m()→obj->prop.m(same). - Instance-chain
$this->factory()->m()→ object = member_call_expression → raw text incl. ARGS →this->factory().m/this->factory($cfg).m(args KEPT — only the scoped fluent branch normalizes; the "fluent 2nd hop" gap, unshipped).foo()->m()→foo().m. - Nullsafe INNER receiver
$a?->b()->c()→ outer is member_call (object = nullsafe_member_call) →a?->b().c. - LITERAL receivers are NOT suppressed (#1230's
LITERAL_RECEIVER_TYPES check lives in the generic Branch B :4397 only) —
"chain"->upper()emits callee"chain".upper(garbage ref, never resolves; PRESERVE). self::m()/static::m()/parent::m()→ scope = relative_scope, text ∈ SKIP → barem.$var::m()→ scope variable_name →var.m.\App\Util::go()→ scope qualified_name → callee\App\Util.go(leading\kept,.-joined — PRESERVE). NOTE: scoped calls are DOT-joined (UserModel.query, neverUserModel::query) — laravel'sModel::methodresolve() pattern only ever sees::refs from OTHER emitters (fn-ref string callables, use refs).
- methodName empty (never in practice — grammar requires name) → fallthrough to no emission.
Branch B (generic, :4312) — function_call_expression: func = function
field. Not a member/scoped shape → else :4518: calleeName = raw func text:
bare helper; qualified \App\Helpers\format_id / App\Helpers\other
(backslashes verbatim, unresolvable downstream — PRESERVE); variable callee
$fn() → $fn; parenthesized/complex → raw text. FCC format_id(...) →
format_id. Post-processing: parenthesized-conversion regex (:4529) can fire
on parenthesized callees — (\s*\*?\s*[A-Za-z_][\w.]*\s*) shapes; php
($x)('a') → func text ($x) → regex needs [A-Za-z_] start after optional
* → $x fails ($) → no rewrite (probe in fixture). Template strip (:4542)
- cpp fn-ptr fan-out (:4556) are c/cpp-gated. Final: one
callsref {callerId = nodeStack top, name, line = startRow+1, column = startColumn (UTF-16)}. extractCall returns immediately when the nodeStack is empty (never — file node pushed).
extractInstantiation (:4610) + anonymous classes
object_creation_expression, from visitNode:1255 AND body walker:5145. ctor =
constructor/type/name FIELDS (php has NONE — probed, the class child is
unfielded) → namedChild(0):
new UserModel()→ name →UserModel.new \App\Models\User()/new Models\User()→ qualified_name → full text\App\Models\User;<-strip no-op; lastDot = max(lastIndexOf('.'), lastIndexOf('::')) — BACKSLASHES NOT HANDLED → ref keeps the FULL qualified text incl. leading\(PRESERVE; resolution handles or drops).new static()/new self()/new parent()→ name (textstatic/self/parent) → instantiates refs literally namedstatic/self/parent— unresolvable, PRESERVE.new $cls()→ variable_name → ref$cls(the$survives — only extractCall strips receiver$). PRESERVE.new class … {}→ anonymous_class (v0.24.2) → className = the WHOLE anon-class source text →<-strip at first<if the body contains one, then the./::suffix logic on what remains, trim → one garbage instantiates ref (PRESERVE — pin exact bytes in the fixture). Then findAnonymousClassBody (:4815 — directclass_body/declaration_listchild) → null on v0.24.2 (list nested in anonymous_class) → no extractAnonymousClass. Descent behavior (§Grammar-bump deltas #1): top-level → methods extract as file-levelfunctionnodes (extractMethod :1747 gate → extractFunction; the object-literal parent check :1751 doesn't match declaration_list); in-body → no nodes, inner calls attribute to the enclosing symbol; base_clause/class_interface_clause of the anon class emit NOTHING either way (extractInheritance runs only from extract{Class,…}).- Ref position = the object_creation_expression. Children still recursed (visitNode :1255 leaves skipChildren false when no anonBody; body walker :5145 continues) → ctor-argument calls get their own refs.
- Param-default
new NullMailer()inside a signature emits NOTHING — the method walk covers thebodyfield only; formal_parameters are walked exclusively by extractPhpTypeRefs (type nodes only). PRESERVE.
Static-member / value-read refs (:4750-4808) — php IS in STATIC_MEMBER_LANGS (:345)
Called ONLY from the body walker (:5218) — top-level reads emit nothing.
MEMBER_ACCESS_TYPES (:323) php rows: class_constant_access_expression
(:328), scoped_property_access_expression (:329). NOTE
member_access_expression (:325, listed for C#) ALSO matches php's $x->y —
recv = object field = variable_name → not an accepted recv type → no-op, but
the walker must still evaluate it (and any name-object member access —
FOO->x — WOULD emit if capitalized; not expressible in valid php).
Mechanics: callee-of-call skip (:4771-4779 — parent ∈ callTypes and its
function/method/first-child starts at this node; scoped_call callees are
scope+name directly, so this fires rarely for php); recv =
object/expression/scope field ?? namedChild(0):
UserModel::class/Foo::CONST/Suit::Hearts→ class_constant_access_expression has NO fields → namedChild(0) =name∈ accepted types (:4791-4794) → capitalized regex^[A-Z][A-Za-z0-9_]*$→referencesref to the class name at the RECEIVER's position.self::CONST/static::X/parent::Y→ namedChild(0) = relative_scope → not accepted → nothing.UserModel::$conn→ scoped_property_access_expression HASscope:field = name → capitalized → referencesUserModel.\App\Models\User::class→ namedChild(0) = qualified_name → not accepted → nothing (PRESERVE).- lowercase receivers (
self,$x) → nothing.
Inheritance — extractInheritance (:5291) for php
Child-type scan on class/interface/enum nodes:
base_clause(:5336, extends): notype_listchild → targets =[child.namedChild(0)]— ONLY THE FIRST base. Classes are fine (single inheritance) butinterface I extends A, B, Cdrops B and C (probed: base_clause children = [name, qualified_name, name]); a qualified first base keeps full text (\Foo\Bar). Oneextendsref, position = the target node. PRESERVE the drop.class_interface_clause(:5437, implements): targets = child.namedChildren (ALL) → oneimplementsref per name/qualified_name — full text each (HasColor,\JsonSerializablewith the backslash). Enum implements ride the same clause.- No other case matches php (
field_declarationGo-shape absent, etc.). - The trait-
useimplements refs come from the visitNode hook (§above), NOT from extractInheritance.
Type-annotation references (:5752-6069) — php IS in TYPE_ANNOTATION_LANGUAGES (:5753)
extractTypeAnnotations dispatches php (:5809-5811) to extractPhpTypeRefs (:6022) — for every FUNCTION and METHOD node (called at :1594/:1816; the property path :2037 is unreachable for php — §Fields):
- params: namedChildren
find(type==='formal_parameters')→ per parameter child (simple_parameter/property_promotion_parameter/variadic_parameter) → per namedChild ∈ PHP_TYPE_NODES (:310 — named_type, optional_type, nullable_type, union_type, intersection_type, disjunctive_normal_form_type, primitive_type) → walkPhpTypePosition. - return/direct: per namedChild of the DECLARATION ∈ PHP_TYPE_NODES →
walkPhpTypePosition (catches the
return_type:child; also a const_declaration'stype:— but consts never reach here). - walkPhpTypePosition (:6040):
primitive_type→ nothing;name→ text not ∈ PHP_PSEUDO_TYPES (:5760 — self static parent mixed object iterable callable void null false true never array int float string bool) → onereferencesref at the name's position;qualified_name→ last\-segment (not-pseudo) → ref at the qualified_name's position; wrapper types → recurse namedChildren. So?Logger→Logger;Mailer|NullMailer→ both;Logger&Deep ...$v→ both;(A&B)|C→ A, B, C;\App\Contracts\Logger $x→Logger. - extractVariableTypeAnnotation (:6074, body
variable_declarators :5230) needs node typevariable_declarator/type_annotation— php has neither → dead for php. property_signature/method_signature (:1282) — TS-only types. extractTypeRefsFromSubtree/BUILTIN_TYPES — never reached for php.
Attributes #[…] — NO decorates refs, ever
extractDecoratorsFor (:4897) runs for functions/methods/classes but: the
attributes: attribute_list direct child is type attribute_list — consider()
accepts only decorator/annotation/marker_annotation/attribute/
modifier_invocation → skipped, and only modifiers-typed children are
descended (:4983 — php has none). Preceding-sibling scan (:5013) stops at the
first non-decorator sibling immediately. The inner attribute nodes are never
reached; attribute ARGUMENTS (#[Deep(param: Logger::class)]) are never
walked. php attributes emit nothing at all — and (probed) the declaration
node's position starts at #[, which is the ONLY observable effect. PRESERVE.
Docstrings (tree-sitter-helpers.ts:95)
php comments (//, #, /* */, /** */) are all node type comment —
accepted by the sibling scan. Consecutive preceding named siblings accumulate
(unshift → source order). DOCSTRING_WRAPPER_TYPES (:55) — none apply to php
(no climbing). Attributes do NOT break the chain (they're INSIDE the
declaration node — contrast rust's attribute_item quirk): /** doc */ #[Attr] class C keeps its docstring. cleanCommentMarkers (:77): /** open →
strip ^\/\*+!? + \*+\/$, then the gm per-line strips — ^\/\/[/!]?\s?,
^#\s? (php # comments), ^\s*\*\s? (block continuation) — all
multiline: the #1329 CRLF ^-after-\r semantics apply; use
js_multiline_strip in docstring.rs (the ONLY (?m)-class regexes in the
php path — php.ts itself has none, and \s* in ^\s*\*\s? is the classic
CRLF \n-eater). Docstrings attach to functions/methods/classes/interfaces/
enums/structs/properties(fields) — NOT to hook-created constants, NOT to
enum_members, NOT to import nodes.
Value-reference edges (:398-931) — php IS in VALUE_REF_LANGS (:401)
Port the full machinery (crib go.rs/java.rs): CODEGRAPH_VALUE_REFS=0 kill;
MAX_VALUE_REF_NODES = 20,000 caps the prune DFS and each reader scan;
isGeneratedFile skip.
- Targets (captureValueRefScope :735, runs inside createNode): kind
constant|variable — php mints ONLY
constant(hook) — name length ≥3 AND/[A-Z_]/, parent id prefix ∈ {file:, class:, module:, struct:, enum:} — top-level consts (under file: — or the file even when a namespace node exists? NO: with a namespace pushed, parent =namespace:…→ NOT accepted → QUIRK: in a namespaced file, top-levelconsttargets are DROPPED (the namespace node id prefixnamespace:is not in the list); un-namespaced files (drupal.modules, scripts) keep them. Class consts (class:) and enum consts (enum:) qualify; interface/trait consts (interface:/trait:) do NOT. fileScopeValueCounts bumps per name. - Reader scopes: every function/method/constant node (+variable — none).
- Shadow prune (:803-878): the declarator switch has NO php cases that
resolve —
assignment(:829) is Python's node (php uses assignment_expression),property_declaration(:856) matches php's node type but its Kotlin/Swift extraction path (variable_declarationchild /namefield / value_binding_pattern) yields null → bump(null) no-op. declCounts stays empty → no php target is ever pruned (matches the :899 comment —$varlives in another namespace). The walker still must bound the (no-op) DFS identically or skip it — either is byte-identical since it emits nothing. - Emission (:880-930): per reader scope DFS (php bodies are children — the
Dart/Pascal sibling pull :891 is inert); match node type
name(the php-specific reader type, :908;identifier/constant/simple_identifiernever occur in php) whose text maps to a target, target ≠ self, name ≠ scope's own name, deduped per (scope,target) → EDGE {source: scopeId, target, kind:'references', metadata:{valueRef:true}}. Because EVERY phpnamenode matches — const reads (MAX_RETRIES), the const half ofself::MAX, but ALSO thenameINSIDEvariable_name($MAX_RETRIES), member names (->MAX_RETRIES), call names, interpolated$Xin strings — any textual occurrence of a target name inside a reader's subtree emits the edge. PRESERVE (precision leans on the [A-Z_]-ish target-name gate). Const-element readers:const A = OTHER;— reader scope is the const_element; its ownname(A) is skipped via target==self/name==scope checks;OTHERemits if a target.
Function-as-value capture (#756) — PHP_SPEC (function-ref.ts:360)
idTypes = ∅ (bare identifiers/names are NEVER candidates); dispatch:
arguments → args; layers: argument → null (descend named children);
special: {encapsed_string, string, array_creation_expression}. No
unwrap/ungatedModes/addressOfOnly. Capture fires from visitNode:990, body
walker:5137, and scanFnRefSubtree (hook-consumed subtrees). Rules
(function-ref.ts:753-834):
- String callable (
'cmp_items'/"cmp"): only when phpEnclosingCallName (:822 — ≤4 parent hops to afunction_call_expression, aborting at member/scoped calls: method-call HOFs never qualify) is ∈ PHP_CALLABLE_HOFS (:347 — array_map, array_filter, array_walk[_recursive], array_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). Content = thestring_contentchild's trimmed text;^[A-Za-z_][A-Za-z0-9_]*$→ bare candidate,^\w+::\w+$-shaped (Cls::method) → qualified candidate — both skipGate: true (flush :712 — bypasses definedHere/imports). QUIRK: namespaced strings ('App\Svc\fn') match neither regex → dropped. Note the qualified form ALSO always-flushes via the::rule (:709). - Array callable (ANY call's arguments, no HOF gate): exactly-2-element
array_creation_expression; el0 = namedChild(0).namedChild(0), el1 likewise; el1 must be string/encapsed_string with simple-name content; el0 = variable_name with text$this→ candidatethis.<m>(always flushes, :709); el0 = class_constant_access_expression whose namedChild(1) text ===class([Foo::class, 'm']) →Foo::m(always flushes).['Cls', 'm'](string receiver) → nothing. Positions: string-callable refs at the STRING node; array-callable refs at the el1 string node. - explicitRef = true for every php candidate (idTypes empty) — irrelevant at
flush (no addressOfOnly). Flush dedupe
${fromNodeId}|${name}→ referenceKindfunction_ref.
Closure-collection pass & other non-players
- CC_LANGUAGES (resolution/callback-synthesizer.ts:77) = {swift, kotlin} — php is OUT of closure-collection (synthesis-side anyway; nothing to port).
- Chained-call #750 languages (the :4408 call-receiver re-encode list — cpp,
c, kotlin, swift, rust, go, scala): php is NOT in it; php's only chain
re-encode is the :4155 scoped fluent (
Cls::f().m), plus the accidental raw-text shapes (foo().m,this->factory().m) documented above. - Value-ref shadow prune, csharp/dart/scala/etc. branches: inert as noted.
- STATIC-member: in (§above). LITERAL_RECEIVER_TYPES: php-inert (Branch A has no literal check).
Frameworks (stay TS-side — pin the walker's output contract)
- laravelResolver (laravel.ts): detect
artisan/app/Http/Kernel.php. extract() (.phpfiles only) regexesRoute::METHOD(...)/Route::resourceover stripCommentsForRegex'd source →routenodes with LITERAL ids`route:${filePath}:${line}:${METHOD}:${path}`(NOT hashed) + handler refs (Cls@method/Cls) — framework refs carry filePath+language (unlike extraction refs). resolve() consumesModel::method(only ever produced by fn-ref string callables / use refs — extraction scoped calls are DOT-joined) andController@method. No walker dependency beyond method/class node names + kinds. - drupalResolver (drupal.ts): languages ['php','yaml']. extract() on
.routing.yml→ route nodes; on hook files (.module/.install/.theme/.inc) AND every.php→ hook refs whose fromNodeId is RECONSTRUCTED asgenerateNodeId(filePath, 'function', funcName, lineNum)(drupal.ts:248) with lineNum = the line of the^function\s+(\w+)\s*\(regex match (drupal.ts:236) — the walker's function-node ids/lines must match byte-for-byte or every Drupal hook edge dangles (attribute-prefixed functions already mismatch today — the regex finds thefunctionline, the node starts at#[— preserved wire truth). Known latent perf bug at drupal.ts:387 (getNodesByKind('function')per hook ref, the #1180 class) — context only, do NOT fix in this arc. - Resolution-side consumers of extraction shapes (never ported, listed for
the wire contract): resolveIncludePath (import-resolver.ts:682-758,
path-shaped
importsrefs), theFoo\Bar::Bazuse-ref resolution + PHP_PROP_SHAPE /().-chain handling (resolution/index.ts:935/1183, name-matcher.ts:1525), inferLocalReceiverType php patterns (name-matcher.ts:1210-1217).
Parity mechanics (all have bitten before)
- Emission order per §Node creation — file → namespace → source-order walk → fn-refs → value-ref edges. Refs interleave with nodes exactly as the TS call sites do (inheritance refs BEFORE the body's; a method's type-refs before its body's calls).
- generateNodeId inputs: (filePath, kind, name, startRow+1) — name has NO
$for fields, IS the fullApp\Contracts\Loggerfor import nodes, the package name for the namespace node; line = declaration start (= attribute_list start when attributes present; = const_element line for consts; = property_element line for fields; = enum_case line for members; = whole-declaration line for grouped-import nodes). - UTF-16 columns + slices (textutil::col16/slice_utf16): every
ref/node column,
startIndex/endIndexsubstrings (getNodeText), and the include-path/type/signature texts. php sources are full of multibyte strings — the torture fixture needs a non-ASCII line before a symbol. - CRLF: probed — the v0.24.2 scanner parses CRLF heredocs/nowdocs/
docblocks cleanly and identically to old. The only CRLF-sensitive TS logic
is cleanCommentMarkers'
gmstrips (§Docstrings) →js_multiline_strip. CRLF variants of the torture fixture derived in-memory, per the tsjs pattern. - Defer policy: per-file
has_error()→defer:— wasm recovery is canonical. Expected incidence ≈0.0–0.1% on the NEW grammar (§table);--max-deferral 0.1default stands. - MAX_FILE_SIZE / generated-file skips: shared, nothing php-specific.
- No php POST_PASS; no preParse;
sourceIsPreParsednever set for php.
Gates (per plan §5, no exceptions)
- Grammar bump lands FIRST, standalone (the rust pattern, with a php
twist): vendor wasm +
=0.24.2crate pin + VENDORED_WASM_LANGS + kernel-grammar-parityGRAMMAR_LANGUAGES += 'php'in one change, full suite green, before any walker exists. Old-wasm vs new-wasm full-init dump diff (scripts/dump-graph.mjs, cmp) on all three gate repos: the diff is expected NON-EMPTY — every hunk must classify into §Grammar-bump deltas (anon-class shapes, grouped nested clause, formerly-erroring files e.g. monologLevel.php); any OTHER category blocks the bump. - Torture fixtures per
## Fixtures to buildbelow (+ CRLF variants derived in-memory), exercised by the new parity suite. - Parity sweeps (
scripts/kernel-parity.mjs <dir>, order-sensitive full-object,--max-deferral 0.1):/private/tmp/claude-501/-Users-colby-Development-CodeGraph-codegraph/765a9532-0a92-43de-8d50-7c8ca1cb345c/scratchpad/monolog(small, 217 files)…/scratchpad/framework(laravel/framework, medium, 2,999 files)…/scratchpad/symfony(large, 10,736 files) (already cloned; re-clone fresh if gone). Then full-init dump-diffs byte-identical (kernel arm vsCODEGRAPH_KERNEL=0,dump-graph.mjs, cmp) on the same three.
- Suite: new
__tests__/kernel-php-parity.test.ts— torture + CRLF variants + leading-HTML fixture + an intentionally-erroring defer fixture (genuinely broken syntax — e.g. an unclosedfunction f( {— NOT an 8.4 feature, those parse clean on v0.24.2) asserting the kernel defers and wasm output is served; full suite ×2 green withCODEGRAPH_KERNEL_EXPECT=1. DEFAULT_ROUTED += 'php'(kernel/index.ts:37) only after ALL of the above; changelog rides the existing kernel entry.- Post-route sanity: remember §arch-2 — gate repos ride the raw path; a
Laravel APP (artisan present) and a Drupal module are the decoded-path
smoke checks (drupal hook-id reconstruction must still land — one
.modulefixture with a hook docblock).
Fixtures to build
torture.php (the survey's svy-php/torture.php is the seed; every line
below names the branch it pins), a CRLF variant of each fixture derived
in-memory (normalization-proof, per the tsjs pattern), one leading-HTML
mixed file (HTML text + <?php + ?> more HTML + <?= short echo —
absolute row positions of post-HTML symbols, text/text_interpolation
recursion), one intentionally-erroring defer fixture (genuinely broken
syntax — an unclosed function f( { — NOT an 8.4 feature, those parse clean
on v0.24.2; asserts kernel defer: + wasm-served output), and one
.module-named fixture (drupal extension routing + a @Implements hook_x(). docblocked function whose reconstructed node id must match).
torture.php inventory: file-level namespace (+ a second namespace_definition
ignored; braced form → no node); use forms: single, aliased, bare
single-segment (no :: ref), use function, use const, grouped incl.
aliased member AND the nested Sub\Deep SKIP; include/require ×4 incl.
parenthesized + dynamic (nothing); interface multi-extends (first-only);
class extends + implements (qualified \JsonSerializable text); trait decl
use A, B { insteadof / as }(2 implements refs at the use line, nothing else); enum backed + pure + implements + method + const-in-enum + enum_case positions; class consts (multi-element, typed, final) + top-level const (value-ref target only when un-namespaced!); properties: typed, nullable, union, readonly,var, multi-element, static; promotion ctor (type refs only, no field nodes,newdefault emits nothing); methods: visibility default 'public', static, abstract/bodiless,: self/: static→ 'self',: ?Foo,: Foo|Bar→ undefined,: void→ undefined; nested named function in a body; a body-level conditional class (polyfill idiom); closures (function() use (&$x)) + arrow fns (calls attribute to encloser, no nodes); FCCf(...)/$this->m(...)/Cls::m(...)(plain calls refs); call shapes: bare, qualified\A\B\f()(verbatim),$x->m(),$this->m()(bare),$this->prop->m()(this->prop.m), 2-hop$this->a->b->m(),$obj->prop->m(),Cls::m()(Cls.mdot-joined),self::/static::/parent::(bare),$var::m(),\Qual\Cls::m(), fluentCls::factory($a)->m()(Cls::factory().m+ innerCls.factory),$this->factory()->m()(this->factory().margs-kept variant too), nullsafe?->(NOTHING), literal"x"->upper()("x".upper); instantiation:new Cls,new \Q\Cls(full text),new static/self/parent(literal),new $cls($cls), ctor-arg call recursion; anonymous class top-level (file-levelfunctionnodes + the garbage instantiates ref) AND in-body (nothing but attributed calls); static-member readsCls::CONST,Cls::class,Cls::$prop,self::CONST(nothing),\Q\Cls::CONST(nothing), enumSuit::Hearts; match expression;$$var; interpolation"{$this->x} $y"+ heredoc with interpolation + nowdoc; fn-refs:usort($a,'cmp'),array_map('A\B\f',…)(dropped),call_user_func([$this,'m']),[Foo::class,'m'],['Cls','m'](dropped),register_shutdown_function('Cls::m'), a method-call HOF ($x->map('cb')— dropped), non-HOF string arg (dropped); value refs: un-namespaced const + reader incl. a$CONST_NAMEvariable occurrence and an interpolated read; docblocks:/** */multi-line,//+#runs, attribute-does-NOT-break-docstring, docstring-position class WITH attributes (node line =#[line); a non-ASCII (UTF-16) line before a symbol.
Probe artifacts (session scratchpad svy-php/)
variant-probe.cjs (variant/ABI), construct-errors.cjs (old-vs-new
per-construct error matrix), shape-probe-php.cjs + torture.php /
torture-clean.php (full-tree OLD/NEW dumps + shape-torture-clean.diff, the
278-line classified diff), mini-probes.cjs + mini-probes.out (new
static/self, : self, braced namespace, <?=, anon-class both scopes,
qualified calls/new, static locals, trait/interface consts), CRLF inline
probe, error-incidence.cjs (the §incidence table), tree-sitter-php.wasm
(the staged-candidate build), tree-sitter-php/ (tag clone) +
crate-extract/ (tarball) with matching shas.