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).
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).
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).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 59ad8e5e4fde3fe60687a488ab8420612840cc966b83739af1b3a4317ed27ec6php/src/scanner.c 58c92cafe4ebda509c3ad3864fa6fc0e9877bbac26e17a03d23ea2101c291ad5
— a thin wrapper: the real external scanner is the SHARED
common/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 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).
| 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.
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):
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):
declaration_list is a direct child → anon-CLASS node
<T$anon@line> + 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_declarations 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.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.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.Level.php class) go from mangled/error extraction to clean — node/edge
diffs on such files are the bump working as intended.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.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.#[…]: identical attributes: attribute_list > attribute_group >
attribute shape on both; extraction ignores them entirely (§Attributes).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.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.generateNodeId(filePath,'function',name,line).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..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.tryKernelExtractRaw stays eligible.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; 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;
fieldTypes=[property_declaration]. nameField=name, bodyField=body,
paramsField=parameters, returnField=return_type.
Hooks PRESENT (port each exactly):
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.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.static_modifier → true, else
false.namespace_definition] + extractPackage (php.ts:149-156) —
see §Namespace capture.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.
f1ca991)| 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 |
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).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.
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).#[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).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, walk body
field (compound_statement) via visitFunctionBody, pop.body field
missing → no body walk, node still minted.function inner() {} in a method):
visitFunctionBody:5245 → functionTypes + named → extractFunction → a
function node contained by the enclosing method.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.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).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).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.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).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).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.
extractImport, hook-first (:3176). Four php shapes:
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.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).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.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.
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):
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).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.$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.$this->a->b->m() → this->a->b.m (resolver won't match — stays
unresolved). $obj->prop->m() → obj->prop.m (same).$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.$a?->b()->c() → outer is member_call (object =
nullsafe_member_call) → a?->b().c."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).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)
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).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,…}).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.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).self, $x) → nothing.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.field_declaration Go-shape absent, etc.).use implements refs come from the visitNode hook (§above), NOT
from extractInheritance.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):
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_type: child; also a
const_declaration's type: — but consts never reach here).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.variable_declarators :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.#[…] — NO decorates refs, everextractDecoratorsFor (: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.
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.
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.
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 .modules, scripts) keep them. Class consts (class:) and enum
consts (enum:) qualify; interface/trait consts (interface:/trait:) do NOT.
fileScopeValueCounts bumps per name.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.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.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):
'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_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.<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.${fromNodeId}|${name} →
referenceKind function_ref.Cls::f().m), plus the accidental
raw-text shapes (foo().m, this->factory().m) documented above.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..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.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).$ 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).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.gm strips (§Docstrings) → js_multiline_strip.
CRLF variants of the torture fixture derived in-memory, per the tsjs
pattern.has_error() → defer: — wasm recovery is
canonical. Expected incidence ≈0.0–0.1% on the NEW grammar (§table);
--max-deferral 0.1 default stands.sourceIsPreParsed never set for php.=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.## Fixtures to build below (+ CRLF variants
derived in-memory), exercised by the new parity suite.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 vs CODEGRAPH_KERNEL=0, dump-graph.mjs,
cmp) on the same three.__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..module fixture with a hook docblock).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, new default 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);
FCC f(...)/$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.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.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.