# 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 `php` grammar, NOT `php_only`.** Probed: the current wasm parses a mixed HTML+PHP file with root-level `text` / `php_tag` / `text_interpolation` nodes and no errors — that is the `php/` grammar of the two-grammar repo. The bump MUST keep this variant and the kernel walker MUST call **`tree_sitter_php::LANGUAGE_PHP`** (the crate also exports `LANGUAGE_PHP_ONLY` — wrong one; a php_only build ERRORs on any leading HTML, which is a routine Drupal/legacy shape). - **Version: crate `tree-sitter-php` 0.24.2** (crates.io max_stable) = repo tag `v0.24.2` = commit `5b5627faaa290d89eb3d01b9bf47c3bb9e797dea` ("fix: publishing, 0.24.2"). sha256-matched tag ↔ crate tarball: - `php/src/parser.c` `59ad8e5e4fde3fe60687a488ab8420612840cc966b83739af1b3a4317ed27ec6` - `php/src/scanner.c` `58c92cafe4ebda509c3ad3864fa6fc0e9877bbac26e17a03d23ea2101c291ad5` — a thin wrapper: **the real external scanner is the SHARED `common/scanner.h`** (`de8eb36bc8f517ab9f3eaf82e3825d7b3e11b62e9471545f4c906100cfce0e07`), `#include`d 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 from `src/`. - **Build (from the tag's CHECKED-IN parser.c — never `tree-sitter generate`):** ``` git 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 . ``` (brew emcc present; survey artifact: ABI 15, 1,058,082 bytes, sha256 `6545a9a110bc878e26ed329950147e190c83da038bb17e999de646fe6c4d6c82`, left at scratchpad `svy-php/tree-sitter-php.wasm`.) - **Staging plan:** vendor to `src/extraction/wasm/tree-sitter-php.wasm`, add `'php'` to `VENDORED_WASM_LANGS` (grammars.ts:291), pin `tree-sitter-php = "=0.24.2"` in codegraph-kernel/Cargo.toml (crate + wasm move TOGETHER), add `'php'` to `GRAMMAR_LANGUAGES` in `__tests__/kernel-grammar-parity.test.ts:39` and to `grammar_for` in `codegraph-kernel/src/langs.rs` (+ the `LANGUAGES` const). MIT license, same family as the other vendored grammars. `copy-assets` already globs `src/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):** 1. **Anonymous classes get a wrapper node.** OLD: `new class … { }` puts `base_clause`/`class_interface_clause`/`declaration_list` DIRECTLY under `object_creation_expression`; NEW nests them in an **`anonymous_class`** child (`body:` field on the list). Consequences (branches: `findAnonymousClassBody` tree-sitter.ts:4815 and `extractInstantiation` :4610): - OLD behavior: `declaration_list` is a direct child → anon-CLASS node `` + `extends` ref + method nodes (extractAnonymousClass :4837). NEW behavior (what the WALKER implements): `findAnonymousClassBody` finds nothing → **no anon class node, no extends ref**; the walker descends instead — at top level the inner `method_declaration`s hit the methodTypes branch, fail `isInsideClassLikeNode`, and extract as file-level **`function` nodes**; inside a body, `visitForCallsAndStructure` has 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 = the `base_clause` (ref text `extends B`) or `declaration_list`; NEW = the whole `anonymous_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. 2. **Grouped-import nested clause drops.** OLD `use A\{Sub\Deep}` clause is `namespace_use_group_clause > namespace_name > name…` — the inline branch (tree-sitter.ts:3322-3347) finds `namespace_name` and emits import node + ref for `A\Sub` (wrong, but old behavior). NEW clause is `namespace_use_clause > qualified_name` — the branch's `find(c => c.type === 'name')` finds nothing → **that clause is silently skipped** (no import node, no ref). Simple (`Mailer`) and aliased (`Cache as CacheAlias` — children `name`, `as`, `alias: name`; the find returns the FIRST `name`, i.e. the source name) group members behave identically on both. The 3329 predicate already accepts both clause type names. 3. **Old-grammar parse-error files now parse.** Enum-const files (monolog's `Level.php` class) go from mangled/error extraction to clean — node/edge diffs on such files are the bump working as intended. 4. **(Found at bump-gate time, survey-missed.) PHP 8.4 parenthesis-free `new X()->m()` chaining misparse fix.** OLD parses the whole chain as ONE `object_creation_expression` with NO error flag (which is why the survey's error matrix missed it) → extractInstantiation emitted a garbage `X()->m`-shaped instantiates ref; NEW parses correctly as `member_call_expression(object_creation_expression(X), m)` → proper `instantiates 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_name` internals: `namespace_name_as_prefix` wrapper → `prefix:`-fielded children. Every consumer reads `getNodeText` of the whole `qualified_name` or `find(type === 'name'/'namespace_name')` on OTHER nodes — no TS code references `namespace_name_as_prefix` (grepped). Text identical. - `namespace_use_clause` gains a `type:` field (`use function`/`use const` — the keyword moves inside the clause); the hook's `find('namespace_use_clause')` + `find('qualified_name')` path is shape-independent. Same result. - Single aliased `use X as Y`: `namespace_aliasing_clause` → flat `as` + `alias: name`. Hook and emitPhpUseRefs read the `qualified_name` (source name) only. Same. - `property_element`: `variable_name` gains a `name:` field; `property_initializer` wrapper → `= (anon)` + `default_value:` field. The extractField php branch finds by TYPE (`variable_name`, then its `name` child) 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_type` becomes a leaf (anon keyword children like `void`, `mixed`, `iterable`, `false` dropped). All reads are node-type + text. Inert. - `text_interpolation`'s `?>` token → named `php_end_tag` child. No branch touches either; visitNode recursion over it is a no-op. Inert. - `namespace_definition` gains `name:` field on `namespace_name` — extractPackage finds by type. Inert. - `new static()`/`new self()`/`new parent()`: both grammars produce `object_creation_expression > name`; OLD prints an anon keyword child under `name`, NEW is a leaf — text identical (`static`/`self`/`parent`). Inert (the instantiates ref is that literal text — see §extractInstantiation). - `enum_case` gains `value:` field — never read (extractEnumMembers returns after the name-field path). Inert. - attributes `#[…]`: identical `attributes: attribute_list > attribute_group > attribute` shape on both; extraction ignores them entirely (§Attributes). ## Architecture decisions 1. **No preParse.** `phpExtractor` has no `preParse` hook (languages/php.ts — whole file, no such key), so `preParsedSource` (kernel/index.ts:82) is a no-op for php — both arms parse raw bytes. Nothing to hoist. 2. **Laravel/Drupal repos take the DECODED path; the three gate repos do NOT.** `laravelResolver` (resolution/frameworks/laravel.ts:38, `languages:['php']`, detect = `artisan` file or `app/Http/Kernel.php` exists) and `drupalResolver` (drupal.ts:296, `languages:['php','yaml']`, detect = composer.json `drupal/*` deps/name/type, else `.info.yml` + drupal file) BOTH have `extract()` hooks, and parse-worker.ts:93-99 forces any language with an applicable framework `extract()` onto the decoded `extractFromSource` path. monolog / laravel-framework / symfony trip NEITHER detector (no `artisan`, 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. 3. **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)`. 4. **One walker module** (suggest `codegraph-kernel/src/php.rs`), registered in `langs.rs` (`grammar_for` → `tree_sitter_php::LANGUAGE_PHP.into()`, `LANGUAGES` const += "php"); per-file `has_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 same `extractFilePackage` mechanic), the php import trio, and the php type-ref walker; rustlang.rs is the crib for hook-suppressed import fallbacks and the `node_ids` dedupe pattern. 5. **Extensions:** `.php`, and the Drupal set `.module`/`.install`/`.theme`/ `.inc` all map to `php` at detectLanguage (grammars.ts:92-97) — no content sniffing, no dialect. Sweeps and fixtures must include a non-`.php` extension file. MAX_FILE_SIZE (1 MiB, extraction/index.ts:132) and generated-file skips are orchestrator/TS-side and shared. 6. **No POST_PASSES entry** (kernel/index.ts:67 — none for php), so `tryKernelExtractRaw` stays 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_type` field; `optional_type` unwraps to `namedChild(0) ?? rt`; then `primitive_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 (kills `A|B` unions — union_type is neither optional nor named_type, so nameNode = the union node, text = `A|B`, regex fails). PROBED shapes: `: self` and `: static` are **named_type > name** on v0.24.2 → marker `'self'` LIVE for both; `: void`/`: mixed`/`: string` are primitive_type → undefined; `: ?Foo` → optional_type > named_type → `Foo`; `: \App\Models\User` → qualified_name (not named_type) → nameNode = rt → text strips lead `\` → last segment `User`. - **classifyClassNode (php.ts:86)** — trait_declaration → 'trait', else 'class'. - **getVisibility (php.ts:89)** — scan ALL children (`child(i)`, anonymous included) for `visibility_modifier`; its text exactly `public`/`private`/`protected` → that; **no modifier → `'public'`** (php default). Called for functions, methods, classes, enums, structs(n/a), properties via extractField. Note `final_modifier`/`abstract_modifier`/ `readonly_modifier` children 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 type `const_element`: nameNode = its namedChildren `find(type==='name')` (the FIRST `name` — which IS the const name; the value of `const A = OTHER_CONST` is also a `name` node but comes second); skip if none; `ctx.createNode('constant', name, elem, {})` — **position = the const_element**, one node per element (`const A = 1, B = 2` → two `constant` nodes), extra = {} so **no docstring, no signature, no visibility, no isStatic** — a `final public const int X = 5` typed const carries none of that. Returns true → hook-consumed → `scanFnRefSubtree(node,0)` (capture-only; php's dispatch is `arguments`-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 filtered `type === 'name' || type === 'qualified_name'` — the used trait names ONLY (the `use_list` conflict block `{ A::g insteadof B; B::g as protected h; }` is type `use_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 full `Foo\Bar` text), referenceKind: **`implements`**, line/column of the **use_declaration node** (same position for every name in `use 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 = literal `file:${filePath}` (:509), name = basename, qualifiedName = filePath, endLine = `source.split('\n').length`, isExported false. Dedupe/self-checks compare ID STRINGS (`node_ids` vec 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 UserService` mints 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`. `` 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, walk `body` field (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):** `body` field missing → no body walk, node still minted. - **Nested named function inside a body** (`function inner() {}` in a method): visitFunctionBody:5245 → functionTypes + named → extractFunction → a `function` node 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 from `anonymous_function_creation_expression` — both untyped in TS) and `arrow_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) includes `arrow_function` (halts scans at php arrow fns) but NOT `anonymous_function` (scan descends into closures — capture-only). - First-class callable `foo(...)` / `$x->m(...)` / `Cls::m(...)`: an ordinary call node with a `variadic_placeholder` argument → **plain `calls` ref** via extractCall (the function-ref spec deliberately leans on this — see function-ref.ts:361 comment). ### extractClass / extractInterface / extractEnum for php - extractClass (:1679): resolvedBody = `body` field (declaration_list); no skipBodilessClass. Node kind class/trait: docstring, visibility (hook → bare class = 'public'), isExported undefined. extractInheritance (§below), extractCsharpPrimaryCtorParamRefs (no-op — needs `parameter_list` child 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's `base_clause`; body children visited with the interface pushed (methods, consts via hook). - extractEnum (:1914): body required (`body` field). docstring, visibility ('public'), isExported undefined. extractInheritance → class_interface_clause → implements. Body loop: `enum_case` ∈ enumMemberTypes → extractEnumMembers (:1958): **`name` field path → ONE `enum_member` node from `getChildByField(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: 1. **include/require (+_once)** (php.ts:163): phpStaticIncludePath — arg = namedChild(0); `parenthesized_expression` unwraps one level; must be `string` | `encapsed_string`; ALL namedChildren must be `string_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) an `imports` ref {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, `.php` appended if missing. 2. **Single `use`** (incl. `use function`/`use const`/aliased): hook finds `namespace_use_clause` → its `qualified_name` (full text, e.g. `App\Contracts\Logger` — alias NOT included) else its `name` (bare single-segment import, e.g. `use Countable;`) → import node named that + the generic `imports` ref (same shape as includes). THEN the php-only :3224-3227 adds **emitPhpUseRefs** (:3515): clause → qualified_name ?? name → pushPhpUseRef (:3563): strip leading `\`; **no `\` left → RETURN (bare `use 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). 3. **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 type `namespace_use_group_clause` | `namespace_use_clause` (v0.24.2: namespace_use_clause); per clause: nsName = clause's `find('namespace_name')` (v0.24.2: never present) → name = nsName ? its `find('name')` : clause's `find('name')` — FIRST `name` = the SOURCE name (aliases skipped); found → fullPath = `` `${prefix}\\${name}` `` → import node named fullPath (positioned at the whole DECLARATION, signature = full text) + pushPhpUseRef(fullPath) → `A::B` refs. **Nested `Sub\E` clause: 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**. 4. 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): 1. **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.query` ref too — both emitted, like rust chains.) Consumed by the resolution chain matcher (`().` marker); scope text can be `self`/`static`/qualified — emitted verbatim (`self::make().x`). 2. Java this-field unwrap (:4203) — `field_access` only, never php. receiverName = **raw objectField text** with ONE leading `$` stripped (:4215 `replace(/^\$/,'')`): - `$x->m()` → object variable_name `$x` → `x` → callee `x.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}) → bare `m`. - **#1251/#1220 property receiver `$this->prop->m()`** → object = member_access_expression, raw text `$this->prop` → `this->prop` → callee **`this->prop.m`**. The ENTIRE #1251 machinery is RESOLUTION-side (name-matcher.ts:1333-1340 strips `this->`, 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 `$prop` local/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 exact `this->prop.m` encoding + 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 → bare `m`. `$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`, never `UserModel::query`) — laravel's `Model::method` resolve() pattern only ever sees `::` refs from OTHER emitters (fn-ref string callables, use refs). 3. 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 `calls` ref {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 (text `static`/`self`/`parent`) → instantiates refs literally named `static`/`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 — direct `class_body`/`declaration_list` child) → **null on v0.24.2** (list nested in anonymous_class) → no extractAnonymousClass. Descent behavior (§Grammar-bump deltas #1): top-level → methods extract as file-level `function` nodes (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 the `body` field 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_]*$` → `references` ref 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 HAS `scope:` field = name → capitalized → references `UserModel`. - `\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): no `type_list` child → targets = `[child.namedChild(0)]` — **ONLY THE FIRST base**. Classes are fine (single inheritance) but `interface I extends A, B, C` **drops B and C** (probed: base_clause children = [name, qualified_name, name]); a qualified first base keeps full text (`\Foo\Bar`). One `extends` ref, position = the target node. PRESERVE the drop. - **`class_interface_clause`** (:5437, implements): targets = child.namedChildren (ALL) → one `implements` ref per name/qualified_name — full text each (`HasColor`, `\JsonSerializable` with the backslash). Enum implements ride the same clause. - No other case matches php (`field_declaration` Go-shape absent, etc.). - The trait-`use` implements 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's `type:` — 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) → one `references` ref 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_declarator`s :5230) needs node type `variable_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-level `const` targets are DROPPED** (the namespace node id prefix `namespace:` is not in the list); un-namespaced files (drupal `.module`s, 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_declaration` child / `name` field / value_binding_pattern) yields null → bump(null) no-op. declCounts stays empty → **no php target is ever pruned** (matches the :899 comment — `$var` lives 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_identifier` never 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 php `name` node matches — const reads (`MAX_RETRIES`), the const half of `self::MAX`, but ALSO the `name` INSIDE `variable_name` (`$MAX_RETRIES`), member names (`->MAX_RETRIES`), call names, interpolated `$X` in 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 own `name` (A) is skipped via target==self/name==scope checks; `OTHER` emits if a target. ### Function-as-value capture (#756) — PHP_SPEC (function-ref.ts:360) idTypes = ∅ (**bare identifiers/`name`s 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 a `function_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 = the `string_content` child'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` → candidate `this.` (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}` → referenceKind `function_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() (`.php` files only) regexes `Route::METHOD(...)`/`Route::resource` over stripCommentsForRegex'd source → `route` nodes with LITERAL ids `` `route:${filePath}:${line}:${METHOD}:${path}` `` (NOT hashed) + handler refs (`Cls@method`/`Cls`) — framework refs carry filePath+language (unlike extraction refs). resolve() consumes `Model::method` (only ever produced by fn-ref string callables / use refs — extraction scoped calls are DOT-joined) and `Controller@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 as `generateNodeId(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 the `function` line, 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 `imports` refs), the `Foo\Bar::Baz` use-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 full `App\Contracts\Logger` for 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/endIndex` substrings (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' `gm` strips (§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.1` default stands. - MAX_FILE_SIZE / generated-file skips: shared, nothing php-specific. - No php POST_PASS; no preParse; `sourceIsPreParsed` never 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.2` crate pin + VENDORED_WASM_LANGS + kernel-grammar-parity `GRAMMAR_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. monolog `Level.php`); any OTHER category blocks the bump. - **Torture fixtures** per `## Fixtures to build` below (+ CRLF variants derived in-memory), exercised by the new parity suite. - **Parity sweeps** (`scripts/kernel-parity.mjs `, 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 vs `CODEGRAPH_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 unclosed `function 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 with `CODEGRAPH_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 `.module` fixture 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 + `` more HTML + `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.m` dot-joined**), `self::`/`static::`/`parent::` (bare), `$var::m()`, `\Qual\Cls::m()`, fluent `Cls::factory($a)->m()` (**`Cls::factory().m`** + inner `Cls.factory`), `$this->factory()->m()` (`this->factory().m` args-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-level `function` nodes + the garbage instantiates ref) AND in-body (nothing but attributed calls); static-member reads `Cls::CONST`, `Cls::class`, `Cls::$prop`, `self::CONST` (nothing), `\Q\Cls::CONST` (nothing), enum `Suit::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_NAME` variable 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, `