فهرست منبع

feat(kernel): R7b PHP walker — php module, tree-sitter-php 0.24.2 bump, php default-routed (#1380)

Fourth and final R7b batch-2 port, checklist-first recipe
(docs/design/php-kernel-port-checklist.md).

Grammar bump first, validated standalone with the diff ENUMERATED + CLASSIFIED
(unlike rust/ruby the php bump is NOT graph-neutral): tree-sitter-php ^0.22
(tree-sitter-wasms, 2023) → v0.24.2, the full HTML-interleaving `php` variant
(the walker calls LANGUAGE_PHP, never PHP_ONLY) — crate pinned =0.24.2, wasm
built from tag 5b5627f's checked-in php/src/parser.c + scanner.c + shared
common/scanner.h (all sha-matched against the crates.io tarball, ABI 14→15).
Old-vs-new full-init diffs decompose completely into: (1) the anonymous_class
wrapper shape (anon-class nodes/methods re-shape — 2,532 rows), (2) grouped
nested-clause skip (absent in the gate repos, fixture-pinned), (3) 32
formerly-erroring files parsing clean (monolog Level.php, symfony
Request/Response with 8.4 property hooks), (4) a survey-missed category found
at gate time: the 8.4 parenthesis-free `new X()->m()` chaining misparse fix
(86 garbage instantiates refs disappear, precision-positive), plus resolution
RIPPLE proven mechanically (every remaining ref-table flip pairs 1:1 with a
resolved edge on the opposite side; node rows byte-stable outside 1/3/4).

Walker (java.rs chassis + the php specifics) preserves bug-for-bug: the
visitNode hook (const_declaration at ANY scope → bare `constant` nodes, values
never walked; trait-use → implements refs WITH filePath via the ruby port's
REF_FLAG_FILE_PATH wire slot), FIRST-namespace whole-file scoping (braced
namespaces scope nothing; namespaced files DROP top-level const value-ref
targets), the import trio (single/aliased/grouped incl. the nested-clause
skip, include/require static-literal-only, `Foo\Bar::Baz` use refs), the
call-encoding zoo (DOT-joined scoped calls, `this->prop.m` #1251 encoding,
`Cls::factory().m` fluent with inner args dropped, nullsafe `?->` emitting
nothing, unsuppressed literal receivers), interface multi-extends
first-base-only drop, anon-class methods as file-level functions (top) or
vanishing (in-body), property type-hints emitting no field refs, the
final-modifier-as-type signature quirk, HOF-gated string callables
(skipGate) + array callables, and the `name`-node value-ref reader.

Gates: sweeps 0-diff monolog 217/217, laravel-framework 3007/3008, symfony
10726/10737 (13,950 files byte-parity; 12 deferrals = exactly the predicted
genuinely-broken fixtures, ≈0–0.1%); full-init dumps byte-identical ×3
(16.1k/354.2k/702.8k lines); kernel-php-parity suite (torture + drupal
.module + leading-HTML fixtures, CRLF variants, wire-flag pin, defer) + php
grammar-parity row; full suite 2,622 green ×2 under CODEGRAPH_KERNEL_EXPECT=1.
DEFAULT_ROUTED += php (13 languages).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 1 ماه پیش
والد
کامیت
a6c62d7

+ 2 - 1
CHANGELOG.md

@@ -11,7 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
-- Indexing TypeScript, TSX, JavaScript, JSX, Java, Python, Go, C, C++, Rust, C#, and Ruby projects is faster: parsing and symbol extraction now run in a native engine when a prebuilt binary is available for your platform (release bundles include one), producing exactly the same graph — verified byte-for-byte against the previous engine on real repositories, from small libraries up to vscode-, dubbo-, django-, git-, protobuf-, tokio-, rust-analyzer-, jellyfin-, and rails-scale codebases (Lombok-generated members, C function-pointer tables, and Unreal-Engine-style macro-heavy headers included; CUDA and Metal sources ride the C++ path). The speedup is largest on resource-constrained machines like CI runners. No setup needed: platforms without the native binary, and individual files with syntax errors, automatically use the previous engine, and `CODEGRAPH_KERNEL=0` turns the native path off entirely.
+- Indexing TypeScript, TSX, JavaScript, JSX, Java, Python, Go, C, C++, Rust, C#, Ruby, and PHP projects is faster: parsing and symbol extraction now run in a native engine when a prebuilt binary is available for your platform (release bundles include one), producing exactly the same graph — verified byte-for-byte against the previous engine on real repositories, from small libraries up to vscode-, dubbo-, django-, git-, protobuf-, tokio-, rust-analyzer-, jellyfin-, rails-, and symfony-scale codebases (Lombok-generated members, C function-pointer tables, and Unreal-Engine-style macro-heavy headers included; CUDA and Metal sources ride the C++ path). The speedup is largest on resource-constrained machines like CI runners. No setup needed: platforms without the native binary, and individual files with syntax errors, automatically use the previous engine, and `CODEGRAPH_KERNEL=0` turns the native path off entirely.
 - Reference resolution now runs in parallel on large projects. When a project has enough pending references to make it worthwhile (roughly 150k+, typical for big Java/Kotlin/Spring codebases), resolution fans out across worker threads while results are applied in the exact order the single-threaded path would have used — the graph comes out byte-for-byte identical, about twice as fast end-to-end on a 4,000-file Java project in our testing. Small projects keep the single-threaded path automatically (the fan-out costs more than it saves there). Set `CODEGRAPH_NO_PARALLEL_RESOLVE=1` to disable, or `CODEGRAPH_PARALLEL_RESOLVE_MIN=<count>` to tune when it engages.
 - Indexing large projects got another sizeable speedup — about a quarter less wall-clock on the same 4,000-file Java project, with the graph still byte-for-byte identical. Two changes: the database no longer interleaves expensive checkpoint housekeeping into the middle of resolution on a fresh index (it's folded once at the end instead), and while one batch's results are being written out, the worker threads are already resolving the next batch instead of sitting idle.
 - The dynamic-dispatch analysis that runs at the end of indexing (callback, event, and framework wiring) now runs its passes in parallel on large projects, cutting that stage roughly in half there — and a pass that crashes now retries safely instead of failing the whole index, which also makes very large codebases that previously died in this stage more likely to index to completion. Graphs remain byte-for-byte identical.
@@ -30,6 +30,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - TypeScript, TSX, and JavaScript files now parse with up-to-date grammars — modern syntax such as `using` declarations and import attributes no longer trips parse errors that could drop surrounding symbols. (The previously bundled grammars dated from 2023.)
 - Rust files also parse with an up-to-date grammar now (the previously bundled build dated from 2023), which additionally sharpens method-call attribution: calls through struct fields resolve with receiver context instead of falling back to ambiguous bare-name matching, removing a class of wrong call edges on common names like `len` and `start`.
 - Ruby files also parse with an up-to-date grammar now (the previously bundled build dated from early 2024), which fixes a misparse of safe-navigation operator-method calls (`recv&.!= x`) that had recorded the wrong callee name.
+- PHP files also parse with an up-to-date grammar now (the previously bundled build dated from 2023): files using modern PHP features — enum constants, PHP 8.4 property hooks, parenthesis-free `new X()->method()` chaining — no longer hit parse errors or misparses that dropped or garbled their symbols, so codebases like Symfony and Laravel index substantially more accurately.
 - Searching or exploring by field names now finds the code that defines them. A query made of object keys or API field names (`profileInfo isTrialEligible quotaInfo billingMethod`) used to return unrelated results while the defining files never appeared, because three retrieval steps each dropped multi-word camelCase terms: an internal case-comparison bug, a match step that only considered classes (never functions or methods), and exploration seeding that required exact symbol-name matches. All three are fixed — `codegraph_explore` with a bag of field names now surfaces the controllers and services that assemble those fields. (#1196)
 - `codegraph.json`'s `includeIgnored` works again for the "folder of repos" layout: when one `.gitignore` rule covers a parent directory (`/repos/`) holding several embedded git repositories, opting in the individual repos (`"includeIgnored": ["repos/a/"]` — the exact spelling `codegraph init`'s own hint suggests) previously matched nothing and indexed zero files, looping the same suggestion back at you. Both spellings now work — name the parent directory to opt in everything under it, or name individual repos to opt in just those — and the hint no longer re-suggests repos that are already configured. (#1295)
 - Method calls on literals (`", ".join(...)` in Python, `"x".split(...)` in JavaScript, and the like) no longer produce call edges to unrelated project functions that happen to share the builtin's name — a codebase with a function called `join`, `get`, or `update` could show phantom callers from every string-builtin use. Additionally, a function nested inside another function is now only matched as a call target from inside its container, since it isn't reachable from anywhere else. Blast-radius and affected-test results get cleaner on Python and JavaScript codebases especially. (#1230)

+ 12 - 0
__tests__/fixtures/kernel-parity/TortureHtml.php

@@ -0,0 +1,12 @@
+<html><body>
+<h1>Leading HTML keeps absolute rows</h1>
+<?php
+
+function html_helper(): void
+{
+    html_call();
+}
+?>
+<p>interleaved text</p>
+<?= html_echo() ?>
+</body></html>

+ 11 - 0
__tests__/fixtures/kernel-parity/TortureModule.module

@@ -0,0 +1,11 @@
+<?php
+
+const MODULE_MAX = 25;
+
+/**
+ * @Implements hook_cron().
+ */
+function torture_cron() {
+  $v = MODULE_MAX;
+  other_module_call();
+}

+ 219 - 0
__tests__/fixtures/kernel-parity/torture.php

@@ -0,0 +1,219 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Contracts\Logger;
+use App\Contracts\Cache as CacheAlias;
+use Countable;
+use function App\Helpers\format_id;
+use const App\Config\MAX_TRIES;
+use App\Models\{User, Post as PostAlias, Sub\Deep};
+
+require 'lib/plain.php';
+require_once('lib/parens.php');
+include 'lib/inc.php';
+include_once 'lib/inc_once.php';
+require __DIR__ . '/dynamic.php';
+
+const TOP_LEVEL_MAX = 10;
+
+// non-ASCII before a symbol: café ünïcode line
+function top_helper(?Logger $log, User|PostAlias $u, Logger&Countable $lc, (Foo&Bar)|Baz $dnf, \App\Models\User $qual, string $s, int ...$rest): UserModel
+{
+    top_body_call();
+    return new UserModel();
+}
+
+/** Doc for Documented. */
+#[Registry(param: Logger::class)]
+class Documented extends BaseThing implements HasColor, \JsonSerializable
+{
+    use SoftDeletes, Notify\Deeper;
+    use ConflictA, ConflictB {
+        ConflictA::greet insteadof ConflictB;
+        ConflictB::greet as protected greetB;
+    }
+
+    const MULTI_A = 1, MULTI_B = 2;
+    final public const int TYPED_MAX = 5;
+
+    public ?Logger $logger, $fallback;
+    private CacheAlias|string $union;
+    protected static iterable $registry;
+    public readonly int $count;
+    var $legacy;
+    final public Foo $finalTyped;
+
+    public function __construct(private Logger $promoted, protected string $name = 'x', ICache $plain = new NullMailer())
+    {
+    }
+
+    /** Doc over attribute. */
+    #[Route('/x')]
+    public function withAttr(): void
+    {
+    }
+
+    public static function make(): static
+    {
+        return new static();
+    }
+
+    public function selfRet(): self
+    {
+        return $this;
+    }
+
+    public function nullableRet(): ?Logger
+    {
+        return null;
+    }
+
+    public function unionRet(): Foo|Bar
+    {
+        return new Foo();
+    }
+
+    protected function callsZoo($x, User $u, $obj, $var, $cls, $arr, $a)
+    {
+        helper();
+        \App\Helpers\format_id(1);
+        App\Helpers\other(2);
+        $x->m1();
+        $this->m2();
+        $this->prop->m3();
+        $this->a->b->m4();
+        $obj->prop->m5();
+        UserModel::query();
+        self::sHelper();
+        static::sHelper();
+        parent::pHelper();
+        $var::vm();
+        \Qual\Cls::qm();
+        UserModel::factory($x)->where('a');
+        $this->factory($x)->go();
+        foo()->fluent();
+        $a?->maybe()->chained();
+        "chain"->upper();
+        $fn = 'x';
+        $fn();
+        ($x)('arg');
+        strlen(...);
+        $this->m2(...);
+        Cls::sm(...);
+        new UserModel();
+        new \App\Models\User();
+        new Models\User(1);
+        new static();
+        new self();
+        new parent();
+        new $cls();
+        $anon = new class extends BaseAnon implements IAnon {
+            public function anonMethod(): void
+            {
+                inner_anon_call();
+            }
+        };
+        new Widget(make_arg());
+        $m = match ($x) {
+            1 => one_case(),
+            default => other_case(),
+        };
+        $$x = 5;
+        $interp = "{$this->x} and $u prefix";
+        $here = <<<EOT
+          heredoc {$this->y} text
+        EOT;
+        $now = <<<'EOT'
+          nowdoc plain
+        EOT;
+        echo SomeCls::CONST_READ;
+        $clsName = UserModel::class;
+        $propRead = UserModel::$conn;
+        $rel = self::REL_CONST;
+        $qualRead = \Qual\Cls::QCONST;
+        $suit = Suit::Hearts;
+    }
+
+    public function nester($arr, $x): void
+    {
+        function innerNamed(): void
+        {
+            inner_call();
+        }
+        if (!class_exists('Poly')) {
+            class Poly
+            {
+                const POLY_MAX = 3;
+
+                public function pm(): void
+                {
+                    poly_call();
+                }
+            }
+        }
+        $c = function () use (&$x) {
+            closure_call();
+        };
+        $a = fn ($v) => arrow_call($v);
+        usort($arr, 'cmp_items');
+        array_map('App\Svc\namespaced_fn', $arr);
+        call_user_func([$this, 'm2']);
+        call_user_func([UserModel::class, 'sm']);
+        call_user_func(['Cls', 'sm']);
+        register_shutdown_function('Cls::shutdown');
+        $x->map('not_captured');
+        plain_call('not_a_hof_string');
+    }
+
+    public function reader(): int
+    {
+        $sum = MULTI_A + TYPED_MAX;
+        $s = "interp TYPED_MAX read: {$this->x} MULTI_B";
+        $varOccurrence = $MULTI_A;
+        return $sum + self::TYPED_MAX;
+    }
+}
+
+interface Shape extends Base1, Base2, \Qual\Base3
+{
+    public function area(): float;
+
+    const SHAPE_KIND = 'geo';
+}
+
+trait SoftDeletes
+{
+    const TRAIT_CONST = 1;
+
+    public function restore(): void
+    {
+        $this->doRestore();
+    }
+}
+
+enum Suit: string implements HasColor
+{
+    case Hearts = 'H';
+    case Spades = 'S';
+
+    const ENUM_MAX = 4;
+
+    public function color(): string
+    {
+        return enum_color($this);
+    }
+}
+
+enum Pure
+{
+    case A;
+    case B;
+}
+
+abstract class AbstractBase
+{
+    abstract protected function hook(): void;
+}

+ 1 - 1
__tests__/kernel-grammar-parity.test.ts

@@ -36,7 +36,7 @@ const kernelBuilt = fs.existsSync(KERNEL_PATH);
 
 // Every kernel-capable language. `jsx` shares the javascript grammar on BOTH
 // paths (langs.rs mirrors WASM_GRAMMAR_FILES), so the distinct grammars are:
-const GRAMMAR_LANGUAGES: Language[] = ['typescript', 'tsx', 'javascript', 'java', 'python', 'go', 'c', 'cpp', 'rust', 'csharp', 'ruby'];
+const GRAMMAR_LANGUAGES: Language[] = ['typescript', 'tsx', 'javascript', 'java', 'python', 'go', 'c', 'cpp', 'rust', 'csharp', 'ruby', 'php'];
 
 describe.skipIf(!kernelBuilt)('kernel↔wasm grammar parity', () => {
   beforeAll(async () => {

+ 149 - 0
__tests__/kernel-php-parity.test.ts

@@ -0,0 +1,149 @@
+/**
+ * Kernel↔wasm PHP extraction parity (R7b of the kernel migration).
+ *
+ * Asserts the native walker (codegraph-kernel/src/php.rs) produces the SAME
+ * ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
+ * unresolved refs compared as canonicalized multisets — over the checked-in
+ * torture fixtures:
+ *
+ *  - torture.php          — file-level namespace scoping, the use-import trio
+ *    (single/aliased/bare/function/const + grouped incl. the nested `Sub\Deep`
+ *    SKIP), include/require ×4 + dynamic (nothing), the visitNode hook (consts
+ *    at every scope, trait-use implements WITH filePath — the v2 ref-flag wire
+ *    path), interface multi-extends first-only drop, the call-encoding zoo
+ *    (`this->prop.m`, DOT-joined scoped calls, `Cls::factory().m` fluent,
+ *    nullsafe `?->` nothing, literal receivers kept), instantiation shapes
+ *    (qualified verbatim, `new static/self/parent` literal, `$cls`, the
+ *    anonymous-class garbage ref + file-level-function methods), static value
+ *    reads, php type refs, HOF string/array callables, value-ref targets
+ *    (namespaced top-level consts DROPPED), heredoc/nowdoc/interpolation,
+ *    attributes shifting node lines without emitting.
+ *  - TortureModule.module — drupal extension routing + un-namespaced
+ *    top-level const value-ref target + hook-docblocked function.
+ *  - TortureHtml.php      — leading/interleaved HTML (absolute row positions),
+ *    `<?=` short echo.
+ *
+ * CRLF variants are derived in-memory (#1329 docblock semantics). The
+ * full-repo sweep lives in scripts/kernel-parity.mjs (monolog /
+ * laravel-framework / symfony for the §5 gate); this suite keeps the invariant
+ * alive in `npm test`. Skips when no kernel binary is staged;
+ * CODEGRAPH_KERNEL_EXPECT=1 turns that into a failure (kernel-scaffold.test.ts).
+ */
+
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
+import type { ExtractionResult } from '../src/types';
+
+const KERNEL_PATH = path.join(
+  __dirname,
+  '..',
+  'codegraph-kernel',
+  'prebuilds',
+  `${process.platform}-${process.arch}`,
+  'codegraph-kernel.node'
+);
+const kernelBuilt = fs.existsSync(KERNEL_PATH);
+
+const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'kernel-parity');
+
+function canon(result: ExtractionResult): { nodes: string[]; edges: string[]; refs: string[] } {
+  return {
+    nodes: result.nodes
+      .map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort()))
+      .sort(),
+    edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
+    refs: result.unresolvedReferences
+      .map((r) => JSON.stringify(r, Object.keys(r).sort()))
+      .sort(),
+  };
+}
+
+const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
+let savedEnv: Record<string, string | undefined>;
+
+describe.skipIf(!kernelBuilt)('kernel PHP extraction parity', () => {
+  beforeAll(async () => {
+    await initGrammars();
+    await loadGrammarsForLanguages(['php']);
+  });
+
+  beforeEach(() => {
+    savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
+    resetKernelForTests();
+  });
+
+  afterEach(() => {
+    for (const k of ENV_KEYS) {
+      if (savedEnv[k] === undefined) delete process.env[k];
+      else process.env[k] = savedEnv[k];
+    }
+    resetKernelForTests();
+  });
+
+  function assertParity(filePath: string, source: string, minNodes = 3): void {
+    process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+    delete process.env.CODEGRAPH_KERNEL;
+    const viaKernel = tryKernelExtract(filePath, source, 'php');
+    expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
+
+    process.env.CODEGRAPH_KERNEL = '0';
+    const viaWasm = extractFromSource(filePath, source, 'php');
+    delete process.env.CODEGRAPH_KERNEL;
+
+    const k = canon(viaKernel!);
+    const w = canon(viaWasm);
+    expect(k.nodes, `${filePath}: nodes`).toEqual(w.nodes);
+    expect(k.edges, `${filePath}: edges`).toEqual(w.edges);
+    expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
+    expect(viaWasm.nodes.length).toBeGreaterThanOrEqual(minNodes);
+  }
+
+  const FIXTURES: Array<{ file: string; minNodes: number }> = [
+    { file: 'torture.php', minNodes: 45 },
+    { file: 'TortureModule.module', minNodes: 3 },
+    { file: 'TortureHtml.php', minNodes: 2 },
+  ];
+
+  for (const { file, minNodes } of FIXTURES) {
+    it(`${file}: namespace, hook, imports, call zoo, value refs`, () => {
+      const src = fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf8');
+      assertParity(`fixtures/${file}`, src, minNodes);
+    });
+
+    // CRLF variant — the shape every Windows autocrlf checkout has. Derived in
+    // memory so no platform or editor can silently normalize it away; pins the
+    // JS-multiline-^ docblock semantics (#1329) plus heredoc/nowdoc CRLF
+    // parsing through the external scanner.
+    it(`${file} CRLF parity`, () => {
+      const src = fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf8');
+      const crlf = src.replace(/(?<!\r)\n/g, '\r\n');
+      assertParity(`fixtures/${file} (crlf)`, crlf, minNodes);
+    });
+  }
+
+  it('trait-use implements refs carry filePath through the v2 ref-flag wire path', () => {
+    const src = '<?php\nclass W {\n  use SoftDeletes;\n}\n';
+    process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+    delete process.env.CODEGRAPH_KERNEL;
+    const viaKernel = tryKernelExtract('src/W.php', src, 'php');
+    expect(viaKernel).not.toBeNull();
+    const impl = viaKernel!.unresolvedReferences.find((r) => r.referenceKind === 'implements');
+    expect(impl?.referenceName).toBe('SoftDeletes');
+    expect(impl?.filePath).toBe('src/W.php');
+  });
+
+  it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => {
+    const broken = '<?php\nfunction f( {\n  return }} 12 (\n';
+    process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+    delete process.env.CODEGRAPH_KERNEL;
+    expect(tryKernelExtract('src/broken.php', broken, 'php')).toBeNull();
+    process.env.CODEGRAPH_KERNEL = '0';
+    const viaWasm = extractFromSource('src/broken.php', broken, 'php');
+    delete process.env.CODEGRAPH_KERNEL;
+    expect(viaWasm.nodes.some((n) => n.kind === 'file')).toBe(true);
+  });
+});

+ 3 - 3
__tests__/kernel-scaffold.test.ts

@@ -73,11 +73,11 @@ describe.skipIf(!kernelBuilt)('kernel scaffold', () => {
   });
 
   it('TS/JS family + Java + Python + Go route to the kernel by default; others stay wasm', () => {
-    for (const lang of ['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', 'ruby'] as const) {
+    for (const lang of ['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', 'ruby', 'php'] as const) {
       expect(kernelRoutes(lang), lang).toBe(true);
     }
-    expect(kernelRoutes('php')).toBe(false);
-    expect(tryKernelExtract('src/a.php', '<?php function f() {}\n', 'php')).toBeNull();
+    expect(kernelRoutes('kotlin')).toBe(false);
+    expect(tryKernelExtract('src/a.kt', 'fun f() {}\n', 'kotlin')).toBeNull();
     // CODEGRAPH_KERNEL_LANGS REPLACES the default set when present.
     process.env.CODEGRAPH_KERNEL_LANGS = 'tsx';
     expect(kernelRoutes('typescript')).toBe(false);

+ 11 - 0
codegraph-kernel/Cargo.lock

@@ -58,6 +58,7 @@ dependencies = [
  "tree-sitter-go",
  "tree-sitter-java",
  "tree-sitter-javascript",
+ "tree-sitter-php",
  "tree-sitter-python",
  "tree-sitter-ruby",
  "tree-sitter-rust",
@@ -553,6 +554,16 @@ version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
 
+[[package]]
+name = "tree-sitter-php"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
 [[package]]
 name = "tree-sitter-python"
 version = "0.23.6"

+ 3 - 0
codegraph-kernel/Cargo.toml

@@ -38,6 +38,9 @@ tree-sitter-c-sharp = "=0.23.5"
 # ruby: content bump, ABI stays 14 (the v0.23.1 tag predates the ABI-15
 # generator) — kernel-grammar-parity asserts same-revision, not same-ABI.
 tree-sitter-ruby = "=0.23.1"
+# php: the walker calls LANGUAGE_PHP (the full HTML-interleaving variant the
+# wasm ships) — NEVER LANGUAGE_PHP_ONLY, which errors on leading HTML.
+tree-sitter-php = "=0.24.2"
 
 [build-dependencies]
 napi-build = "2"

+ 5 - 2
codegraph-kernel/src/langs.rs

@@ -15,9 +15,9 @@ use tree_sitter::Language;
 
 /// Languages this kernel binary can extract (reported by contractInfo;
 /// TS-side routing policy decides what actually routes).
-pub const LANGUAGES: [&str; 12] = [
+pub const LANGUAGES: [&str; 13] = [
     "typescript", "tsx", "javascript", "jsx", "java", "python", "go", "c", "cpp", "rust",
-    "csharp", "ruby",
+    "csharp", "ruby", "php",
 ];
 
 pub fn grammar_for(language: &str) -> Option<Language> {
@@ -41,6 +41,9 @@ pub fn grammar_for(language: &str) -> Option<Language> {
         // R7b: v0.23.1, sha-matched with the vendored wasm (grammars.ts).
         // Content bump only — the tag's parser.c is still ABI 14.
         "ruby" => Some(tree_sitter_ruby::LANGUAGE.into()),
+        // R7b: v0.24.2, the full HTML-interleaving variant — LANGUAGE_PHP,
+        // NEVER LANGUAGE_PHP_ONLY (which errors on leading HTML).
+        "php" => Some(tree_sitter_php::LANGUAGE_PHP.into()),
         _ => None,
     }
 }

+ 2 - 0
codegraph-kernel/src/lib.rs

@@ -25,6 +25,7 @@ mod ids;
 mod go;
 mod java;
 mod langs;
+mod php;
 mod ruby;
 mod rustlang;
 mod textutil;
@@ -217,6 +218,7 @@ pub fn extract_file(file_path: String, content: String, language: String) -> Res
         "rust" => rustlang::extract(&file_path, &content).map_err(Error::from_reason)?,
         "csharp" => csharp::extract(&file_path, &content).map_err(Error::from_reason)?,
         "ruby" => ruby::extract(&file_path, &content).map_err(Error::from_reason)?,
+        "php" => php::extract(&file_path, &content).map_err(Error::from_reason)?,
         _ => tsjs::extract(&file_path, &content, &language).map_err(Error::from_reason)?,
     };
     Ok(ExtractBuffers {

+ 1524 - 0
codegraph-kernel/src/php.rs

@@ -0,0 +1,1524 @@
+//! PHP extraction — a faithful Rust port of `TreeSitterExtractor`'s PHP paths
+//! (src/extraction/tree-sitter.ts) plus languages/php.ts.
+//!
+//! Same porting contract as the other walkers: behavior parity, bug-for-bug.
+//! The authoritative quirk list is docs/design/php-kernel-port-checklist.md —
+//! including what this file preserves on purpose: the visitNode hook consumes
+//! const_declaration (constants at ANY scope, values never walked) and
+//! trait-`use` (implements refs WITH filePath — the v2 ref-flag wire slot,
+//! shipped with ruby) before the ladder; the FIRST file-level namespace scopes
+//! the whole walk (braced namespaces scope nothing); anonymous classes on the
+//! v0.24.2 grammar mint NO anon-class node (top-level methods become file-level
+//! functions, in-body methods vanish) and their instantiates ref is the whole
+//! anon-class text run through the suffix logic; scoped calls are DOT-joined
+//! (`UserModel.query`); `$this->prop->m()` emits `this->prop.m` (the #1251
+//! machinery is resolution-side); nullsafe `?->` emits nothing; literal
+//! receivers are not suppressed; interface multi-extends drops all but the
+//! first base; property type-hints emit no refs from field nodes. Positions in
+//! UTF-16 code units. Files with parse errors defer to wasm (≈0–0.1%).
+
+use crate::buffers::{
+    build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
+    RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE, NONE, NONE_STR,
+    REF_FLAG_FILE_PATH,
+};
+use crate::docstring::preceding_docstring;
+use crate::ids;
+use crate::textutil as util;
+use regex::Regex;
+use std::collections::{HashMap, HashSet};
+use std::sync::OnceLock;
+use tree_sitter::{Node, Parser};
+
+const MAX_VALUE_REF_NODES: usize = 20_000;
+
+/// NAME_STOPLIST (function-ref.ts).
+fn is_stoplisted(name: &str) -> bool {
+    matches!(
+        name,
+        "this" | "self" | "super" | "null" | "nil" | "true" | "false" | "undefined" | "new"
+            | "NULL" | "nullptr" | "None"
+    )
+}
+
+/// PHP_NON_CLASS_RETURN (languages/php.ts:37).
+fn is_php_non_class_return(lc: &str) -> bool {
+    matches!(
+        lc,
+        "array" | "string" | "int" | "integer" | "float" | "double" | "bool" | "boolean"
+            | "void" | "mixed" | "never" | "null" | "false" | "true" | "object" | "callable"
+            | "iterable" | "resource"
+    )
+}
+
+/// PHP_PSEUDO_TYPES (tree-sitter.ts:5760).
+fn is_php_pseudo_type(name: &str) -> bool {
+    matches!(
+        name,
+        "self" | "static" | "parent" | "mixed" | "object" | "iterable" | "callable" | "void"
+            | "null" | "false" | "true" | "never" | "array" | "int" | "float" | "string" | "bool"
+    )
+}
+
+/// PHP_TYPE_NODES (tree-sitter.ts:310).
+fn is_php_type_node(kind: &str) -> bool {
+    matches!(
+        kind,
+        "named_type" | "optional_type" | "nullable_type" | "union_type" | "intersection_type"
+            | "disjunctive_normal_form_type" | "primitive_type"
+    )
+}
+
+/// PHP_CALLABLE_HOFS (function-ref.ts:347).
+fn is_php_callable_hof(name: &str) -> bool {
+    matches!(
+        name,
+        "array_map" | "array_filter" | "array_walk" | "array_walk_recursive" | "array_reduce"
+            | "usort" | "uasort" | "uksort"
+            | "array_udiff" | "array_udiff_assoc" | "array_uintersect" | "array_uintersect_assoc"
+            | "call_user_func" | "call_user_func_array"
+            | "forward_static_call" | "forward_static_call_array"
+            | "preg_replace_callback" | "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"
+    )
+}
+
+/// `/^[A-Za-z_]\w*$/` with JS's ASCII `\w`.
+fn ascii_ident_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Za-z_][0-9A-Za-z_]*$").unwrap())
+}
+/// String-callable simple-name shape (`/^[A-Za-z_][A-Za-z0-9_]*$/`).
+fn simple_callable_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap())
+}
+/// String-callable qualified shape (`/^\w+::\w+$/`, JS ASCII `\w`).
+fn qualified_callable_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[0-9A-Za-z_]+::[0-9A-Za-z_]+$").unwrap())
+}
+/// extractStaticMemberRef's capitalized-receiver test.
+fn capitalized_re() -> &'static Regex {
+    static RE: OnceLock<Regex> = OnceLock::new();
+    RE.get_or_init(|| Regex::new(r"^[A-Z][A-Za-z0-9_]*$").unwrap())
+}
+
+struct Scope {
+    row: u32,
+    kind: &'static str,
+    name: String,
+}
+
+#[derive(Default)]
+struct Extra {
+    docstring: Option<String>,
+    signature: Option<String>,
+    visibility: Option<u8>,
+    is_static: Option<bool>,
+    return_type: Option<String>,
+}
+
+struct ValueScope<'t> {
+    row: u32,
+    node: Node<'t>,
+    name: String,
+}
+
+struct Cand {
+    from: u32,
+    name: String,
+    line: u32,
+    column_byte: usize,
+    row: usize,
+    skip_gate: bool,
+}
+
+pub struct Walker<'t> {
+    src: &'t str,
+    file_path: &'t str,
+    line_starts: Vec<usize>,
+    arena: Arena,
+    tables: Tables,
+    stack: Vec<Scope>,
+    node_ids: Vec<String>,
+    defined_fn_names: HashSet<String>,
+    imported_names: HashSet<String>,
+    fn_ref_cands: Vec<Cand>,
+    fs_values: HashMap<String, u32>,
+    fs_value_counts: HashMap<String, u32>,
+    value_scopes: Vec<ValueScope<'t>>,
+}
+
+pub fn extract(file_path: &str, source: &str) -> Result<EmitOut, String> {
+    let grammar = crate::langs::grammar_for("php").ok_or("no php grammar")?;
+    let t0 = std::time::Instant::now();
+    let mut parser = Parser::new();
+    parser
+        .set_language(&grammar)
+        .map_err(|e| format!("set_language(php) failed: {e}"))?;
+    let tree = parser
+        .parse(source, None)
+        .ok_or_else(|| "parser returned null tree".to_string())?;
+    if tree.root_node().has_error() {
+        return Err("defer: parse tree contains errors — wasm recovery is canonical".to_string());
+    }
+
+    let mut w = Walker {
+        src: source,
+        file_path,
+        line_starts: util::line_starts(source),
+        arena: Arena::default(),
+        tables: Tables::default(),
+        stack: Vec::new(),
+        node_ids: Vec::new(),
+        defined_fn_names: HashSet::new(),
+        imported_names: HashSet::new(),
+        fn_ref_cands: Vec::new(),
+        fs_values: HashMap::new(),
+        fs_value_counts: HashMap::new(),
+        value_scopes: Vec::new(),
+    };
+
+    let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
+    let base_name = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
+    let mut flags = BoolFlags::default();
+    flags.set(FLAG_IS_EXPORTED, false);
+    let file_id = w.arena.put(&ids::file_node_id(file_path));
+    let name_ref = w.arena.put(base_name);
+    let qn_ref = w.arena.put(file_path);
+    w.tables.push_node(&NodeRow {
+        kind: node_kind_index("file").unwrap(),
+        visibility: 0,
+        flags,
+        start_line: 1,
+        end_line: line_count,
+        start_column: 0,
+        end_column: 0,
+        name: name_ref,
+        qualified_name: qn_ref,
+        id: file_id,
+        docstring: NONE_STR,
+        signature: NONE_STR,
+        decorators: NONE_STR,
+        type_parameters: NONE_STR,
+        return_type: NONE_STR,
+        extra_json: NONE_STR,
+    });
+    w.node_ids.push(ids::file_node_id(file_path));
+    w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() });
+
+    // extractFilePackage: the FIRST namespace_definition among the root's
+    // direct namedChildren; braced namespaces (a compound_statement /
+    // declaration_list child) make NO node and scope NOTHING. The node stays
+    // pushed for the whole walk — QNs become `App\Services::Name` and import
+    // nodes/refs hang off it.
+    let root = tree.root_node();
+    let mut pkg_pushed = false;
+    for i in 0..root.named_child_count() {
+        let Some(child) = root.named_child(i) else { continue };
+        if child.kind() != "namespace_definition" {
+            continue;
+        }
+        let ns_name = (0..child.named_child_count())
+            .filter_map(|j| child.named_child(j))
+            .find(|c| c.kind() == "namespace_name");
+        let has_body = (0..child.named_child_count())
+            .filter_map(|j| child.named_child(j))
+            .any(|c| matches!(c.kind(), "compound_statement" | "declaration_list"));
+        if let Some(ns_name) = ns_name {
+            if !has_body {
+                let pkg = w.text(ns_name).to_string();
+                if !pkg.is_empty() {
+                    if let Some(row) = w.create_node("namespace", &pkg, child, Extra::default()) {
+                        w.stack.push(Scope { row, kind: "namespace", name: pkg });
+                        pkg_pushed = true;
+                    }
+                }
+            }
+        }
+        break;
+    }
+
+    w.visit_node(root);
+    w.flush_fn_ref_candidates();
+    w.flush_value_refs();
+    if pkg_pushed {
+        w.stack.pop();
+    }
+    w.stack.pop();
+
+    let duration_ms = t0.elapsed().as_secs_f64() * 1000.0;
+    let meta = build_meta(&w.tables, w.arena.len(), NONE_STR, duration_ms);
+    Ok(EmitOut {
+        meta,
+        nodes: w.tables.nodes,
+        edges: w.tables.edges,
+        refs: w.tables.refs,
+        arena: w.arena.into_vec(),
+    })
+}
+
+impl<'t> Walker<'t> {
+    fn text(&self, node: Node) -> &'t str {
+        &self.src[node.byte_range()]
+    }
+    fn line_of(&self, node: Node) -> u32 {
+        node.start_position().row as u32 + 1
+    }
+    fn col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.start_position().row, node.start_byte())
+    }
+    fn end_col_of(&self, node: Node) -> u32 {
+        util::col16(self.src, &self.line_starts, node.end_position().row, node.end_byte())
+    }
+    fn top_row(&self) -> u32 {
+        self.stack.last().map(|s| s.row).unwrap_or(0)
+    }
+    fn inside_class_like(&self) -> bool {
+        self.stack
+            .last()
+            .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module"))
+            .unwrap_or(false)
+    }
+
+    fn push_ref(&mut self, from_row: u32, name: &str, kind_code: u8, line: u32, column: u32) {
+        let name_ref = self.arena.put(name);
+        self.tables.push_ref(&RefRow {
+            from_idx: from_row,
+            kind: kind_code,
+            line,
+            column,
+            reference_name: name_ref,
+            candidates: NONE_STR,
+            from_id_str: NONE_STR,
+        });
+        if kind_code == edge_kind_index("imports").unwrap() {
+            if util::simple_name().is_match(name) {
+                self.imported_names.insert(name.to_string());
+            } else if let Some(c) = util::qualified_import().captures(name) {
+                self.imported_names.insert(c[1].to_string());
+            }
+        }
+    }
+
+    fn push_ref_at(&mut self, from_row: u32, name: &str, kind_code: u8, node: Node) {
+        self.push_ref(from_row, name, kind_code, self.line_of(node), self.col_of(node));
+    }
+
+    // --- createNode ------------------------------------------------------------
+
+    fn create_node(&mut self, kind: &'static str, name: &str, node: Node<'t>, extra: Extra) -> Option<u32> {
+        if name.is_empty() {
+            return None;
+        }
+        let start_line = self.line_of(node);
+        let id = ids::node_id(self.file_path, kind, name, start_line);
+        let end_line = node.end_position().row as u32 + 1; // no resolveBody for php
+
+        let qualified = {
+            let mut parts: Vec<&str> = Vec::new();
+            for s in &self.stack {
+                if s.kind != "file" {
+                    parts.push(&s.name);
+                }
+            }
+            let mut qn = parts.join("::");
+            if !qn.is_empty() {
+                qn.push_str("::");
+            }
+            qn.push_str(name);
+            qn
+        };
+
+        let mut flags = BoolFlags::default();
+        if let Some(v) = extra.is_static {
+            flags.set(FLAG_IS_STATIC, v);
+        }
+        let name_ref = self.arena.put(name);
+        let qn_ref = self.arena.put(&qualified);
+        let id_ref = self.arena.put(&id);
+        let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref());
+        let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref());
+        let ret_ref = opt_str(&mut self.arena, extra.return_type.as_deref());
+        let row = self.tables.push_node(&NodeRow {
+            kind: node_kind_index(kind).unwrap(),
+            visibility: extra.visibility.unwrap_or(0),
+            flags,
+            start_line,
+            end_line,
+            start_column: self.col_of(node),
+            end_column: self.end_col_of(node),
+            name: name_ref,
+            qualified_name: qn_ref,
+            id: id_ref,
+            docstring: doc_ref,
+            signature: sig_ref,
+            decorators: NONE_STR, // php attributes never emit decorates refs
+            type_parameters: NONE_STR,
+            return_type: ret_ref,
+            extra_json: NONE_STR,
+        });
+        self.node_ids.push(id);
+
+        let parent_row = self.top_row();
+        self.tables.push_edge(&EdgeRow {
+            source_idx: parent_row,
+            target_idx: row,
+            kind: edge_kind_index("contains").unwrap(),
+            provenance: 0,
+            line: NONE,
+            column: NONE,
+            metadata_json: NONE_STR,
+            source_id_str: NONE_STR,
+            target_id_str: NONE_STR,
+        });
+
+        if kind == "function" || kind == "method" {
+            self.defined_fn_names.insert(name.to_string());
+        }
+        // captureValueRefScope — with a namespace pushed, top-level constants
+        // have a `namespace` parent (NOT in the accepted set) and are dropped
+        // as targets; class/enum consts qualify, interface/trait ones don't.
+        let target_kind_ok = kind == "constant" || kind == "variable";
+        if target_kind_ok
+            && util::utf16_len(name) >= 3
+            && util::has_upper_or_underscore().is_match(name)
+        {
+            let parent_ok = self
+                .stack
+                .last()
+                .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum"))
+                .unwrap_or(false);
+            if parent_ok {
+                self.fs_values.insert(name.to_string(), row);
+                *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1;
+            }
+        }
+        if matches!(kind, "function" | "method" | "constant" | "variable") {
+            self.value_scopes.push(ValueScope { row, node, name: name.to_string() });
+        }
+        Some(row)
+    }
+
+    fn extract_name(&self, node: Node) -> String {
+        if let Some(name_node) = node.child_by_field_name("name") {
+            return self.text(name_node).to_string();
+        }
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                if matches!(c.kind(), "identifier" | "type_identifier" | "simple_identifier" | "constant") {
+                    return self.text(c).to_string();
+                }
+            }
+        }
+        "<anonymous>".to_string()
+    }
+
+    // --- hooks (languages/php.ts) ------------------------------------------------
+
+    /// getVisibility: any `visibility_modifier` child with one of the three
+    /// texts; none → public (the php default).
+    fn visibility_of(&self, node: Node) -> u8 {
+        for i in 0..node.child_count() {
+            let Some(child) = node.child(i) else { continue };
+            if child.kind() == "visibility_modifier" {
+                match self.text(child) {
+                    "public" => return 1,
+                    "private" => return 2,
+                    "protected" => return 3,
+                    _ => {}
+                }
+            }
+        }
+        1 // PHP defaults to public
+    }
+
+    fn is_static(&self, node: Node) -> bool {
+        (0..node.child_count())
+            .filter_map(|i| node.child(i))
+            .any(|c| c.kind() == "static_modifier")
+    }
+
+    /// extractPhpReturnType — `self`/`static` collapse to the `'self'` marker
+    /// (#608 chained-call fuel); primitives/unions → None.
+    fn return_type_of(&self, node: Node) -> Option<String> {
+        let mut rt = node.child_by_field_name("return_type")?;
+        if rt.kind() == "optional_type" {
+            rt = rt.named_child(0).unwrap_or(rt);
+        }
+        if rt.kind() == "primitive_type" {
+            return None;
+        }
+        let name_node = if rt.kind() == "named_type" { rt.named_child(0).unwrap_or(rt) } else { rt };
+        let text = self.text(name_node).trim();
+        let text = text.strip_prefix('\\').unwrap_or(text);
+        if text.is_empty() {
+            return None;
+        }
+        let last = text.rsplit('\\').next().unwrap_or(text);
+        let lc = last.to_lowercase();
+        if matches!(lc.as_str(), "self" | "static" | "this" | "$this") {
+            return Some("self".to_string());
+        }
+        if is_php_non_class_return(&lc) {
+            return None;
+        }
+        if !ascii_ident_re().is_match(last) {
+            return None; // unions/intersections/complex
+        }
+        Some(last.to_string())
+    }
+
+    // --- the visitNode hook (php.ts:108) ------------------------------------------
+
+    fn try_visit_hook(&mut self, node: Node<'t>) -> bool {
+        match node.kind() {
+            // Class/interface/trait/enum/top-level constants: one `constant`
+            // node per const_element, NO extras, values never walked.
+            "const_declaration" => {
+                let elements: Vec<Node> = (0..node.named_child_count())
+                    .filter_map(|i| node.named_child(i))
+                    .filter(|c| c.kind() == "const_element")
+                    .collect();
+                for elem in elements {
+                    let name_node = (0..elem.named_child_count())
+                        .filter_map(|i| elem.named_child(i))
+                        .find(|c| c.kind() == "name");
+                    let Some(name_node) = name_node else { continue };
+                    let name = self.text(name_node).to_string();
+                    self.create_node("constant", &name, elem, Extra::default());
+                }
+                true
+            }
+            // Trait use inside a class-like body: one `implements` ref per
+            // used name (full qualified text), all at the use_declaration's
+            // position — WITH filePath (the hook sets ctx.filePath; v2 flag).
+            "use_declaration" => {
+                let names: Vec<Node> = (0..node.named_child_count())
+                    .filter_map(|i| node.named_child(i))
+                    .filter(|c| matches!(c.kind(), "name" | "qualified_name"))
+                    .collect();
+                let parent = self.top_row();
+                let implements = edge_kind_index("implements").unwrap();
+                let line = self.line_of(node);
+                let col = self.col_of(node);
+                for n in names {
+                    let name_ref = self.arena.put(self.text(n));
+                    self.tables.push_ref_flagged(
+                        &RefRow {
+                            from_idx: parent,
+                            kind: implements,
+                            line,
+                            column: col,
+                            reference_name: name_ref,
+                            candidates: NONE_STR,
+                            from_id_str: NONE_STR,
+                        },
+                        REF_FLAG_FILE_PATH,
+                    );
+                }
+                true
+            }
+            _ => false,
+        }
+    }
+
+    // --- the dispatcher (visitNode, PHP-relevant branches) ------------------------
+
+    fn visit_node(&mut self, node: Node<'t>) {
+        if self.try_visit_hook(node) {
+            self.scan_fn_ref_subtree(node, 0);
+            return;
+        }
+
+        let kind = node.kind();
+        let mut skip_children = false;
+
+        self.maybe_capture_fn_refs(node);
+
+        if kind == "function_definition" {
+            // functionTypes; method_declaration is not in it, so this is
+            // always extractFunction (php functions can't be class members).
+            self.extract_function(node);
+            skip_children = true;
+        } else if kind == "class_declaration" {
+            self.extract_class(node, "class");
+            skip_children = true;
+        } else if kind == "trait_declaration" {
+            // classifyClassNode → 'trait'.
+            self.extract_class(node, "trait");
+            skip_children = true;
+        } else if kind == "method_declaration" {
+            // Inside a class-like → method; outside (an anonymous class's
+            // members at TOP level — grammar-bump delta #1) the 1747 gate
+            // bounces to extractFunction: a file-level `function` node.
+            if self.inside_class_like() {
+                self.extract_method(node);
+            } else {
+                self.extract_function(node);
+            }
+            skip_children = true;
+        } else if kind == "interface_declaration" {
+            self.extract_interface(node);
+            skip_children = true;
+        } else if kind == "enum_declaration" {
+            self.extract_enum(node);
+            skip_children = true;
+        } else if kind == "property_declaration" && self.inside_class_like() {
+            self.extract_field(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
+        } else if matches!(
+            kind,
+            "namespace_use_declaration" | "include_expression" | "include_once_expression"
+                | "require_expression" | "require_once_expression"
+        ) {
+            self.extract_import(node);
+            // children still visited (importTypes sets no skipChildren)
+        } else if matches!(
+            kind,
+            "function_call_expression" | "member_call_expression" | "scoped_call_expression"
+        ) {
+            self.extract_call(node);
+        } else if kind == "object_creation_expression" {
+            self.extract_instantiation(node);
+            if let Some(anon_body) = find_anonymous_class_body(node) {
+                // v0.24.2 nests the declaration_list in `anonymous_class`, so
+                // this never fires — mirrored for shape.
+                self.extract_anonymous_class(node, anon_body);
+                skip_children = true;
+            }
+        }
+        // text / php_tag / text_interpolation / namespace_definition /
+        // nullsafe_member_call_expression / expression_statement / closures /
+        // match / attributes: no branch — children visited.
+
+        if !skip_children {
+            for i in 0..node.named_child_count() {
+                if let Some(c) = node.named_child(i) {
+                    self.visit_node(c);
+                }
+            }
+        }
+    }
+
+    // --- visitFunctionBody --------------------------------------------------------
+
+    fn visit_function_body(&mut self, body: Node<'t>) {
+        self.visit_for_calls_and_structure(body);
+    }
+
+    fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        self.maybe_capture_fn_refs(node);
+
+        if matches!(
+            kind,
+            "function_call_expression" | "member_call_expression" | "scoped_call_expression"
+        ) {
+            self.extract_call(node);
+        } else if kind == "object_creation_expression" {
+            self.extract_instantiation(node);
+            if let Some(anon_body) = find_anonymous_class_body(node) {
+                self.extract_anonymous_class(node, anon_body);
+                return;
+            }
+        }
+
+        // Static value reads (`Cls::CONST`, `Cls::$prop`, `Cls::class`).
+        self.extract_static_member_ref(node);
+
+        // Nested NAMED functions; body-level class/trait/enum/interface
+        // declarations (the polyfill idiom). NOTE: no method_declaration
+        // branch — in-body anonymous-class methods vanish (delta #1), and the
+        // visitNode hook does NOT run here (a const_declaration in a body-level
+        // class still extracts via extractClass's own visitNode body walk).
+        if kind == "function_definition" {
+            let name = self.extract_name(node);
+            if name != "<anonymous>" {
+                self.extract_function(node);
+                return;
+            }
+        }
+        if kind == "class_declaration" {
+            self.extract_class(node, "class");
+            return;
+        }
+        if kind == "trait_declaration" {
+            self.extract_class(node, "trait");
+            return;
+        }
+        if kind == "enum_declaration" {
+            self.extract_enum(node);
+            return;
+        }
+        if kind == "interface_declaration" {
+            self.extract_interface(node);
+            return;
+        }
+
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.visit_for_calls_and_structure(c);
+            }
+        }
+    }
+
+    // --- extractors ----------------------------------------------------------------
+
+    fn extract_function(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        if name == "<anonymous>" {
+            if let Some(body) = node.child_by_field_name("body") {
+                self.visit_function_body(body);
+            }
+            return;
+        }
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: None, // no getSignature hook
+            visibility: Some(self.visibility_of(node)),
+            is_static: Some(self.is_static(node)),
+            return_type: self.return_type_of(node),
+        };
+        let Some(row) = self.create_node("function", &name, node, extra) else { return };
+        self.extract_php_type_refs(node, row);
+        // decorators: none.
+        self.stack.push(Scope { row, kind: "function", name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_method(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            signature: None,
+            visibility: Some(self.visibility_of(node)),
+            is_static: Some(self.is_static(node)),
+            return_type: self.return_type_of(node),
+        };
+        let Some(row) = self.create_node("method", &name, node, extra) else { return };
+        self.extract_php_type_refs(node, row);
+        self.stack.push(Scope { row, kind: "method", name });
+        // Bodiless (interface/abstract) methods still mint nodes, no walk.
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_function_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_class(&mut self, node: Node<'t>, kind: &'static str) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            visibility: Some(self.visibility_of(node)),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node(kind, &name, node, extra) else { return };
+        self.extract_inheritance(node, row);
+        // primary-ctor refs: csharp-only (needs a parameter_list child type);
+        // decorators: none.
+        self.stack.push(Scope { row, kind, name });
+        let body = node.child_by_field_name("body").unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_interface(&mut self, node: Node<'t>) {
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            ..Extra::default() // NO visibility — extractInterface never asks
+        };
+        let Some(row) = self.create_node("interface", &name, node, extra) else { return };
+        self.extract_inheritance(node, row);
+        self.stack.push(Scope { row, kind: "interface", name });
+        let body = node.child_by_field_name("body").unwrap_or(node);
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_enum(&mut self, node: Node<'t>) {
+        let Some(body) = node.child_by_field_name("body") else { return };
+        let name = self.extract_name(node);
+        let extra = Extra {
+            docstring: preceding_docstring(node, self.src),
+            visibility: Some(self.visibility_of(node)),
+            ..Extra::default()
+        };
+        let Some(row) = self.create_node("enum", &name, node, extra) else { return };
+        // class_interface_clause → implements refs; the backing type is never
+        // read (it's not in a base_clause).
+        self.extract_inheritance(node, row);
+        self.stack.push(Scope { row, kind: "enum", name });
+        for i in 0..body.named_child_count() {
+            let Some(child) = body.named_child(i) else { continue };
+            if child.kind() == "enum_case" {
+                self.extract_enum_members(child);
+            } else {
+                self.visit_node(child);
+            }
+        }
+        self.stack.pop();
+    }
+
+    fn extract_enum_members(&mut self, node: Node<'t>) {
+        // name-field path: one enum_member at the enum_case; backed values
+        // (`= 'H'`) never walked.
+        if let Some(name_node) = node.child_by_field_name("name") {
+            let name = self.text(name_node).to_string();
+            self.create_node("enum_member", &name, node, Extra::default());
+        }
+    }
+
+    /// extractField — the php property_element branch (2077-2104): one `field`
+    /// node per element, `$` re-added in the signature only, then RETURN — no
+    /// decorators, no type-annotation refs from fields.
+    fn extract_field(&mut self, node: Node<'t>) {
+        let docstring = preceding_docstring(node, self.src);
+        let visibility = Some(self.visibility_of(node));
+        let is_static = Some(self.is_static(node));
+
+        let prop_elements: Vec<Node> = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .filter(|c| c.kind() == "property_element")
+            .collect();
+        if prop_elements.is_empty() {
+            // The declarator/bare fallbacks find nothing on php shapes.
+            return;
+        }
+        // The type node: first namedChild that isn't a modifier or element.
+        // QUIRK: final_modifier/abstract_modifier are NOT excluded — a
+        // `final public Foo $x` takes `final` as the type text. PRESERVE.
+        let type_node = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| {
+                !matches!(
+                    c.kind(),
+                    "visibility_modifier" | "static_modifier" | "readonly_modifier"
+                        | "property_element" | "var_modifier"
+                )
+            });
+        let type_text = type_node.map(|t| self.text(t).to_string());
+
+        for elem in prop_elements {
+            let var_name = (0..elem.named_child_count())
+                .filter_map(|i| elem.named_child(i))
+                .find(|c| c.kind() == "variable_name");
+            let Some(var_name) = var_name else { continue };
+            let name_node = (0..var_name.named_child_count())
+                .filter_map(|i| var_name.named_child(i))
+                .find(|c| c.kind() == "name");
+            let Some(name_node) = name_node else { continue };
+            let name = self.text(name_node).to_string();
+            let signature = match &type_text {
+                Some(t) => format!("{t} ${name}"),
+                None => format!("${name}"),
+            };
+            self.create_node(
+                "field",
+                &name,
+                elem,
+                Extra {
+                    docstring: docstring.clone(),
+                    signature: Some(signature),
+                    visibility,
+                    is_static,
+                    ..Extra::default()
+                },
+            );
+        }
+    }
+
+    // --- imports -------------------------------------------------------------------
+
+    /// pushPhpUseRef (3563): `Foo\Bar\Baz` → an `imports` ref named
+    /// `Foo\Bar::Baz`; a global-namespace name (no `\` after stripping one
+    /// leading `\`) emits nothing here.
+    fn push_php_use_ref(&mut self, fqn: &str, from_row: u32, node: Node) {
+        let clean = fqn.strip_prefix('\\').unwrap_or(fqn);
+        let Some(last_sep) = clean.rfind('\\') else { return };
+        let name = format!("{}::{}", &clean[..last_sep], &clean[last_sep + 1..]);
+        self.push_ref_at(from_row, &name, edge_kind_index("imports").unwrap(), node);
+    }
+
+    fn extract_import(&mut self, node: Node<'t>) {
+        let kind = node.kind();
+        let import_text = self.text(node).trim().to_string();
+        let imports_kind = edge_kind_index("imports").unwrap();
+
+        if matches!(
+            kind,
+            "include_expression" | "include_once_expression" | "require_expression"
+                | "require_once_expression"
+        ) {
+            // phpStaticIncludePath: static string literals only; dynamic
+            // forms (`__DIR__ . '/x'`, interpolation) emit NOTHING.
+            let mut arg = node.named_child(0);
+            if let Some(a) = arg {
+                if a.kind() == "parenthesized_expression" {
+                    arg = a.named_child(0);
+                }
+            }
+            let Some(arg) = arg else { return };
+            if !matches!(arg.kind(), "string" | "encapsed_string") {
+                return;
+            }
+            let mut content: Option<Node> = None;
+            for i in 0..arg.named_child_count() {
+                let Some(c) = arg.named_child(i) else { continue };
+                if c.kind() != "string_content" {
+                    return; // interpolation/escape → not a static path
+                }
+                if content.is_none() {
+                    content = Some(c);
+                }
+            }
+            let Some(content) = content else { return };
+            let module_name = self.text(content).to_string();
+            if module_name.is_empty() {
+                return;
+            }
+            self.create_node(
+                "import",
+                &module_name,
+                node,
+                Extra { signature: Some(import_text), ..Extra::default() },
+            );
+            let parent = self.top_row();
+            self.push_ref_at(parent, &module_name.clone(), imports_kind, node);
+            return;
+        }
+
+        // namespace_use_declaration.
+        let ns_prefix = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "namespace_name");
+        let use_group = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "namespace_use_group");
+        if let (Some(ns_prefix), Some(use_group)) = (ns_prefix, use_group) {
+            // Grouped `use A\{B, C as D, Sub\E}` — hook declines, the inline
+            // branch emits per-member nodes named `A\B` (first `name` child =
+            // the SOURCE name; a nested `Sub\E` clause has a qualified_name,
+            // no direct `name` → SKIPPED, grammar-bump delta #2). All nodes
+            // and refs sit at the whole declaration's position.
+            let prefix = self.text(ns_prefix).to_string();
+            let clauses: Vec<Node> = (0..use_group.named_child_count())
+                .filter_map(|i| use_group.named_child(i))
+                .filter(|c| {
+                    matches!(c.kind(), "namespace_use_group_clause" | "namespace_use_clause")
+                })
+                .collect();
+            for clause in clauses {
+                let ns_name = (0..clause.named_child_count())
+                    .filter_map(|i| clause.named_child(i))
+                    .find(|c| c.kind() == "namespace_name");
+                let name = match ns_name {
+                    Some(nn) => (0..nn.named_child_count())
+                        .filter_map(|i| nn.named_child(i))
+                        .find(|c| c.kind() == "name"),
+                    None => (0..clause.named_child_count())
+                        .filter_map(|i| clause.named_child(i))
+                        .find(|c| c.kind() == "name"),
+                };
+                if let Some(name) = name {
+                    let full = format!("{prefix}\\{}", self.text(name));
+                    self.create_node(
+                        "import",
+                        &full,
+                        node,
+                        Extra { signature: Some(import_text.clone()), ..Extra::default() },
+                    );
+                    let parent = self.top_row();
+                    self.push_php_use_ref(&full, parent, node);
+                }
+            }
+            return;
+        }
+
+        // Single use (incl. `use function`/`use const`/aliased): the hook's
+        // qualified_name-else-name read; alias never included.
+        let use_clause = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "namespace_use_clause");
+        let Some(use_clause) = use_clause else { return };
+        let target = (0..use_clause.named_child_count())
+            .filter_map(|i| use_clause.named_child(i))
+            .find(|c| c.kind() == "qualified_name")
+            .or_else(|| {
+                (0..use_clause.named_child_count())
+                    .filter_map(|i| use_clause.named_child(i))
+                    .find(|c| c.kind() == "name")
+            });
+        let Some(target) = target else { return }; // hook null → nothing
+        let module_name = self.text(target).to_string();
+        if module_name.is_empty() {
+            return;
+        }
+        self.create_node(
+            "import",
+            &module_name,
+            node,
+            Extra { signature: Some(import_text), ..Extra::default() },
+        );
+        let parent = self.top_row();
+        self.push_ref_at(parent, &module_name.clone(), imports_kind, node);
+        // emitPhpUseRefs → the `Foo\Bar::Baz` ref (bare single-segment `use
+        // Countable;` has no `\` → no `::` ref).
+        self.push_php_use_ref(&module_name, parent, node);
+    }
+
+    // --- calls ---------------------------------------------------------------------
+
+    fn extract_call(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        let caller = self.top_row();
+        let mut callee_name = String::new();
+
+        let name_field = node.child_by_field_name("name");
+        let object_field = node
+            .child_by_field_name("object")
+            .or_else(|| node.child_by_field_name("scope"));
+
+        if let (Some(name_field), Some(object_field)) = (name_field, object_field) {
+            // member_call_expression / scoped_call_expression.
+            let method_name = self.text(name_field);
+
+            // Fluent static-factory `Cls::factory($x)->method()` — encode
+            // `Cls::factory().method` (inner args dropped) and return; the
+            // inner scoped call is also visited by recursion (`Cls.factory`).
+            if !method_name.is_empty() && object_field.kind() == "scoped_call_expression" {
+                let inner_scope = object_field.child_by_field_name("scope");
+                let inner_name = object_field.child_by_field_name("name");
+                let callee = match (inner_scope, inner_name) {
+                    (Some(s), Some(n)) => {
+                        format!("{}::{}().{method_name}", self.text(s), self.text(n))
+                    }
+                    _ => method_name.to_string(),
+                };
+                if !callee.is_empty() {
+                    self.push_ref_at(caller, &callee, edge_kind_index("calls").unwrap(), node);
+                }
+                return;
+            }
+
+            // receiverName = raw receiver text with ONE leading `$` stripped:
+            // `$this->prop->m()` → `this->prop.m` (#1251 encoding — the whole
+            // resolution machinery is TS-side); chains keep args
+            // (`this->factory($cfg).m`); literals are NOT suppressed
+            // (`"chain".upper`); scoped calls are DOT-joined (`UserModel.query`).
+            let receiver_raw = self.text(object_field);
+            let receiver = receiver_raw.strip_prefix('$').unwrap_or(receiver_raw);
+            if !method_name.is_empty() {
+                if matches!(receiver, "self" | "this" | "cls" | "super" | "parent" | "static") {
+                    callee_name = method_name.to_string();
+                } else {
+                    callee_name = format!("{receiver}.{method_name}");
+                }
+            }
+        } else {
+            // function_call_expression: raw func text — bare `helper`,
+            // qualified `\App\Helpers\format_id` verbatim, `$fn` for
+            // variable callees, FCC `f(...)` → `f`.
+            let func = node
+                .child_by_field_name("function")
+                .or_else(|| node.named_child(0));
+            if let Some(func) = func {
+                callee_name = self.text(func).to_string();
+            }
+        }
+
+        if !callee_name.is_empty() {
+            if let Some(c) = util::paren_conversion().captures(&callee_name) {
+                callee_name = c[1].to_string();
+            }
+            self.push_ref_at(caller, &callee_name.clone(), edge_kind_index("calls").unwrap(), node);
+        }
+    }
+
+    fn extract_instantiation(&mut self, node: Node<'t>) {
+        if self.stack.is_empty() {
+            return;
+        }
+        // php has no constructor/type/name FIELDS → namedChild(0). Backslashes
+        // are NOT split by the suffix logic → `new \App\Models\User()` keeps
+        // the full qualified text; `new $cls()` keeps the `$`; an
+        // anonymous_class yields its whole source text through the shared
+        // normalization (garbage, deterministic — preserve).
+        let ctor = node
+            .child_by_field_name("constructor")
+            .or_else(|| node.child_by_field_name("type"))
+            .or_else(|| node.child_by_field_name("name"))
+            .or_else(|| node.named_child(0));
+        let Some(ctor) = ctor else { return };
+        let class_name = strip_generic_and_qualifier(self.text(ctor));
+        if !class_name.is_empty() {
+            let from = self.top_row();
+            self.push_ref_at(from, &class_name, edge_kind_index("instantiates").unwrap(), node);
+        }
+    }
+
+    /// extractAnonymousClass — unreachable on v0.24.2 (the declaration_list
+    /// nests inside `anonymous_class`, so findAnonymousClassBody finds no
+    /// DIRECT child) — mirrored from the shared TS path for shape.
+    fn extract_anonymous_class(&mut self, node: Node<'t>, body: Node<'t>) {
+        let type_node = node
+            .child_by_field_name("constructor")
+            .or_else(|| node.child_by_field_name("type"))
+            .or_else(|| node.child_by_field_name("name"))
+            .or_else(|| node.named_child(0));
+        let mut type_name =
+            type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string());
+        type_name = strip_generic_and_qualifier(&type_name);
+        if type_name.is_empty() {
+            type_name = "Object".to_string();
+        }
+        let anon_name = format!("<{type_name}$anon@{}>", node.start_position().row + 1);
+        let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else {
+            return;
+        };
+        let (line, column) = match type_node {
+            Some(t) => (t.start_position().row as u32, self.col_of(t)),
+            None => (node.start_position().row as u32, self.col_of(node)),
+        };
+        self.push_ref(row, &type_name, edge_kind_index("extends").unwrap(), line, column);
+        self.stack.push(Scope { row, kind: "class", name: anon_name });
+        for i in 0..body.named_child_count() {
+            if let Some(c) = body.named_child(i) {
+                self.visit_node(c);
+            }
+        }
+        self.stack.pop();
+    }
+
+    /// extractStaticMemberRef — php's class_constant_access_expression +
+    /// scoped_property_access_expression (member_access_expression is
+    /// evaluated but its variable_name receiver never passes).
+    fn extract_static_member_ref(&mut self, node: Node<'t>) {
+        if !matches!(
+            node.kind(),
+            "class_constant_access_expression" | "scoped_property_access_expression"
+                | "member_access_expression"
+        ) {
+            return;
+        }
+        if self.stack.is_empty() {
+            return;
+        }
+        let owner = self.top_row();
+        if let Some(parent) = node.parent() {
+            if matches!(
+                parent.kind(),
+                "function_call_expression" | "member_call_expression" | "scoped_call_expression"
+            ) {
+                let callee = parent
+                    .child_by_field_name("function")
+                    .or_else(|| parent.child_by_field_name("method"))
+                    .or_else(|| parent.named_child(0));
+                if let Some(callee) = callee {
+                    if callee.start_byte() == node.start_byte() {
+                        return;
+                    }
+                }
+            }
+        }
+        let recv = node
+            .child_by_field_name("object")
+            .or_else(|| node.child_by_field_name("expression"))
+            .or_else(|| node.child_by_field_name("scope"))
+            .or_else(|| node.named_child(0));
+        let Some(recv) = recv else { return };
+        if matches!(
+            recv.kind(),
+            "identifier" | "type_identifier" | "simple_identifier" | "name" | "scoped_type_identifier"
+        ) {
+            let text = self.text(recv);
+            if capitalized_re().is_match(text) {
+                self.push_ref_at(owner, &text.to_string(), edge_kind_index("references").unwrap(), recv);
+            }
+        }
+    }
+
+    /// extractInheritance — base_clause takes ONLY the first base (interface
+    /// multi-extends drops the rest); class_interface_clause takes ALL
+    /// children unfiltered (full text, incl. leading `\`).
+    fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        let extends_kind = edge_kind_index("extends").unwrap();
+        let implements_kind = edge_kind_index("implements").unwrap();
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            if child.kind() == "base_clause" {
+                if let Some(target) = child.named_child(0) {
+                    let name = self.text(target).to_string();
+                    self.push_ref_at(class_row, &name, extends_kind, target);
+                }
+            } else if child.kind() == "class_interface_clause" {
+                for j in 0..child.named_child_count() {
+                    let Some(iface) = child.named_child(j) else { continue };
+                    let name = self.text(iface).to_string();
+                    self.push_ref_at(class_row, &name, implements_kind, iface);
+                }
+            }
+        }
+    }
+
+    // --- php type refs (extractPhpTypeRefs, 6022) ----------------------------------
+
+    fn extract_php_type_refs(&mut self, node: Node<'t>, from_row: u32) {
+        let params = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "formal_parameters");
+        if let Some(params) = params {
+            for i in 0..params.named_child_count() {
+                let Some(p) = params.named_child(i) else { continue };
+                for j in 0..p.named_child_count() {
+                    let Some(c) = p.named_child(j) else { continue };
+                    if is_php_type_node(c.kind()) {
+                        self.walk_php_type_position(c, from_row);
+                    }
+                }
+            }
+        }
+        for i in 0..node.named_child_count() {
+            let Some(c) = node.named_child(i) else { continue };
+            if is_php_type_node(c.kind()) {
+                self.walk_php_type_position(c, from_row);
+            }
+        }
+    }
+
+    fn walk_php_type_position(&mut self, node: Node<'t>, from_row: u32) {
+        match node.kind() {
+            "primitive_type" => {}
+            "name" => {
+                let name = self.text(node);
+                if !name.is_empty() && !is_php_pseudo_type(name) {
+                    self.push_ref_at(from_row, &name.to_string(), edge_kind_index("references").unwrap(), node);
+                }
+            }
+            "qualified_name" => {
+                let text = self.text(node);
+                let last = text.rsplit('\\').next().unwrap_or("");
+                if !last.is_empty() && !is_php_pseudo_type(last) {
+                    self.push_ref_at(from_row, &last.to_string(), edge_kind_index("references").unwrap(), node);
+                }
+            }
+            _ => {
+                for i in 0..node.named_child_count() {
+                    if let Some(c) = node.named_child(i) {
+                        self.walk_php_type_position(c, from_row);
+                    }
+                }
+            }
+        }
+    }
+
+    // --- function-as-value refs (PHP_SPEC, function-ref.ts:360) --------------------
+
+    fn maybe_capture_fn_refs(&mut self, node: Node<'t>) {
+        if node.kind() != "arguments" {
+            return;
+        }
+        if self.stack.is_empty() {
+            return;
+        }
+        let from = self.top_row();
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.normalize_fn_ref_value(c, from, 0);
+            }
+        }
+    }
+
+    fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        if depth > 4 {
+            return;
+        }
+        match v.kind() {
+            "argument" => {
+                for i in 0..v.named_child_count() {
+                    if let Some(c) = v.named_child(i) {
+                        self.normalize_fn_ref_value(c, from, depth + 1);
+                    }
+                }
+            }
+            // String callable — trustworthy ONLY as an argument to a known
+            // callable-taking core function; skipGate (resolution's
+            // unique-or-drop rule takes over). Namespaced strings drop.
+            "string" | "encapsed_string" => {
+                let Some(callee) = php_enclosing_call_name(v).map(|f| self.text(f)) else {
+                    return;
+                };
+                if !is_php_callable_hof(callee) {
+                    return;
+                }
+                let Some(content) = self.php_string_content(v) else { return };
+                if simple_callable_re().is_match(&content) || qualified_callable_re().is_match(&content)
+                {
+                    self.push_fn_ref_cand(from, &content, v, true);
+                }
+            }
+            // Array callables in ANY call's arguments: `[$this, 'm']` →
+            // this.m; `[Foo::class, 'm']` → Foo::m; `['Cls', 'm']` → nothing.
+            "array_creation_expression" => {
+                if v.named_child_count() != 2 {
+                    return;
+                }
+                let recv = v.named_child(0).and_then(|e| e.named_child(0));
+                let str_el = v.named_child(1).and_then(|e| e.named_child(0));
+                let (Some(recv), Some(str_el)) = (recv, str_el) else { return };
+                if !matches!(str_el.kind(), "encapsed_string" | "string") {
+                    return;
+                }
+                let Some(member) = self.php_string_content(str_el) else { return };
+                if !simple_callable_re().is_match(&member) {
+                    return;
+                }
+                if recv.kind() == "variable_name" && self.text(recv) == "$this" {
+                    let name = format!("this.{member}");
+                    self.push_fn_ref_cand(from, &name, str_el, false);
+                } else if recv.kind() == "class_constant_access_expression" {
+                    let cls = recv.named_child(0);
+                    let kw = recv.named_child(1);
+                    if let (Some(cls), Some(kw)) = (cls, kw) {
+                        if self.text(kw) == "class" {
+                            let name = format!("{}::{member}", self.text(cls));
+                            self.push_fn_ref_cand(from, &name, str_el, false);
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    /// phpStringContent: the string's first string_content child, trimmed.
+    fn php_string_content(&self, node: Node) -> Option<String> {
+        for i in 0..node.named_child_count() {
+            let Some(c) = node.named_child(i) else { continue };
+            if c.kind() == "string_content" {
+                return Some(self.text(c).trim().to_string());
+            }
+        }
+        None
+    }
+
+    fn push_fn_ref_cand(&mut self, from: u32, name: &str, node: Node, skip_gate: bool) {
+        if name.is_empty() || is_stoplisted(name) {
+            return;
+        }
+        let p = node.start_position();
+        self.fn_ref_cands.push(Cand {
+            from,
+            name: name.to_string(),
+            line: p.row as u32 + 1,
+            column_byte: node.start_byte(),
+            row: p.row,
+            skip_gate,
+        });
+    }
+
+    fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        if depth > 12 {
+            return;
+        }
+        // Halts at functionTypes (function_definition) + arrow_function (in
+        // the fixed list); anonymous_function is NOT halted — scans descend
+        // into closures.
+        if depth > 0
+            && matches!(
+                node.kind(),
+                "function_definition" | "arrow_function" | "function_expression" | "lambda_literal"
+                    | "lambda_expression"
+            )
+        {
+            return;
+        }
+        self.maybe_capture_fn_refs(node);
+        for i in 0..node.named_child_count() {
+            if let Some(c) = node.named_child(i) {
+                self.scan_fn_ref_subtree(c, depth + 1);
+            }
+        }
+    }
+
+    fn flush_fn_ref_candidates(&mut self) {
+        let cands = std::mem::take(&mut self.fn_ref_cands);
+        if cands.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+        let mut seen: HashSet<(String, String)> = HashSet::new();
+        for c in cands {
+            // `this.<m>` and `Cls::m` shapes always flush; HOF-position string
+            // callables skip the gate (unique-or-drop at resolution); the rest
+            // gate on defined-in-file ∪ bare single-segment `use` imports
+            // (path-shaped and `::`-shaped import refs match neither regex).
+            if !c.name.starts_with("this.") && !c.name.contains("::") {
+                let skip = c.skip_gate;
+                if !skip
+                    && !self.defined_fn_names.contains(&c.name)
+                    && !self.imported_names.contains(&c.name)
+                {
+                    continue;
+                }
+            }
+            if !seen.insert((self.node_ids[c.from as usize].clone(), c.name.clone())) {
+                continue;
+            }
+            let column = util::col16(self.src, &self.line_starts, c.row, c.column_byte);
+            let name_ref = self.arena.put(&c.name);
+            self.tables.push_ref(&RefRow {
+                from_idx: c.from,
+                kind: FUNCTION_REF_CODE,
+                line: c.line,
+                column,
+                reference_name: name_ref,
+                candidates: NONE_STR,
+                from_id_str: NONE_STR,
+            });
+        }
+    }
+
+    // --- value references ------------------------------------------------------------
+
+    fn flush_value_refs(&mut self) {
+        let scopes = std::mem::take(&mut self.value_scopes);
+        let targets = std::mem::take(&mut self.fs_values);
+        let _counts = std::mem::take(&mut self.fs_value_counts);
+        if std::env::var("CODEGRAPH_VALUE_REFS").as_deref() == Ok("0") {
+            return;
+        }
+        if targets.is_empty() || scopes.is_empty() || util::is_generated_file(self.file_path) {
+            return;
+        }
+
+        // Shadow prune: the per-grammar declarator switch has NO resolving php
+        // cases (`assignment` is python's node; property_declaration's
+        // Kotlin/Swift path yields null) → declCounts stays empty → no php
+        // target is ever pruned. Skipping the scan is byte-identical.
+
+        let refs_kind = edge_kind_index("references").unwrap();
+        for scope in &scopes {
+            let mut seen: HashSet<&str> = HashSet::new();
+            let mut stack: Vec<Node> = vec![scope.node];
+            let mut visited = 0usize;
+            while let Some(n) = stack.pop() {
+                if visited >= MAX_VALUE_REF_NODES {
+                    break;
+                }
+                visited += 1;
+                // `name` is the php-live reader kind — ANY textual occurrence
+                // of a target name in a reader's subtree emits (const reads,
+                // `self::MAX`, `$MAX` variable names, interpolated `$MAX`).
+                if matches!(n.kind(), "identifier" | "constant" | "name" | "simple_identifier") {
+                    let ref_name = self.text(n);
+                    if let Some(&target_row) = targets.get(ref_name) {
+                        let target_id = self.node_ids[target_row as usize].as_str();
+                        if target_id != self.node_ids[scope.row as usize]
+                            && ref_name != scope.name
+                            && !seen.contains(&target_id)
+                        {
+                            seen.insert(target_id);
+                            let meta = self.arena.put(r#"{"valueRef":true}"#);
+                            self.tables.push_edge(&EdgeRow {
+                                source_idx: scope.row,
+                                target_idx: target_row,
+                                kind: refs_kind,
+                                provenance: 0,
+                                line: NONE,
+                                column: NONE,
+                                metadata_json: meta,
+                                source_id_str: NONE_STR,
+                                target_id_str: NONE_STR,
+                            });
+                        }
+                    }
+                }
+                for i in 0..n.named_child_count() {
+                    if let Some(c) = n.named_child(i) {
+                        stack.push(c);
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// The function name node of the php call whose arguments contain `node` —
+/// ≤4 parent hops to a function_call_expression; member/scoped calls abort
+/// (method-call HOFs never qualify). (function-ref.ts:822)
+fn php_enclosing_call_name(node: Node) -> Option<Node> {
+    let mut cur = node.parent();
+    for _ in 0..4 {
+        let c = cur?;
+        if c.kind() == "function_call_expression" {
+            return c.child_by_field_name("function");
+        }
+        if matches!(c.kind(), "member_call_expression" | "scoped_call_expression") {
+            return None;
+        }
+        cur = c.parent();
+    }
+    None
+}
+
+fn find_anonymous_class_body(node: Node) -> Option<Node> {
+    for i in 0..node.named_child_count() {
+        if let Some(child) = node.named_child(i) {
+            if matches!(child.kind(), "class_body" | "declaration_list") {
+                return Some(child);
+            }
+        }
+    }
+    None
+}
+
+/// The shared `new ns.Foo<T>()` normalization: strip `<...` from the first
+/// `<` (index > 0), keep the segment after the last `.`/`::`, strip ONE
+/// leading `:` or `.`, trim. Backslashes are NOT handled — php qualified
+/// names pass through whole.
+fn strip_generic_and_qualifier(raw: &str) -> String {
+    let mut name = raw.to_string();
+    if let Some(lt) = name.find('<') {
+        if lt > 0 {
+            name.truncate(lt);
+        }
+    }
+    let last_dot = name
+        .rfind('.')
+        .map(|i| i as isize)
+        .unwrap_or(-1)
+        .max(name.rfind("::").map(|i| i as isize).unwrap_or(-1));
+    if last_dot >= 0 {
+        name = name[(last_dot as usize + 1)..].to_string();
+        if name.starts_with(':') || name.starts_with('.') {
+            name.remove(0);
+        }
+    }
+    name.trim().to_string()
+}
+
+fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
+    match s {
+        Some(s) => arena.put(s),
+        None => NONE_STR,
+    }
+}

+ 978 - 0
docs/design/php-kernel-port-checklist.md

@@ -0,0 +1,978 @@
+# 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
+     `<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_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`. `<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.
+- 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.<m>` (always
+  flushes, :709); el0 = class_constant_access_expression whose namedChild(1)
+  text === `class` (`[Foo::class, 'm']`) → `Foo::m` (always flushes).
+  `['Cls', 'm']` (string receiver) → nothing. Positions: string-callable refs
+  at the STRING node; array-callable refs at the el1 string node.
+- explicitRef = true for every php candidate (idTypes empty) — irrelevant at
+  flush (no addressOfOnly). Flush dedupe `${fromNodeId}|${name}` →
+  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 <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.
+- **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 + `<?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.
+
+## Probe artifacts (session scratchpad `svy-php/`)
+
+`variant-probe.cjs` (variant/ABI), `construct-errors.cjs` (old-vs-new
+per-construct error matrix), `shape-probe-php.cjs` + `torture.php` /
+`torture-clean.php` (full-tree OLD/NEW dumps + `shape-torture-clean.diff`, the
+278-line classified diff), `mini-probes.cjs` + `mini-probes.out` (new
+static/self, `: self`, braced namespace, `<?=`, anon-class both scopes,
+qualified calls/new, static locals, trait/interface consts), CRLF inline
+probe, `error-incidence.cjs` (the §incidence table), `tree-sitter-php.wasm`
+(the staged-candidate build), `tree-sitter-php/` (tag clone) +
+`crate-extract/` (tarball) with matching shas.

+ 1 - 1
docs/design/rust-kernel-migration-plan.md

@@ -605,7 +605,7 @@ parity before porting the language.
 | python | `languages/python.ts` | T1 | crates.io | Third. Decorator extraction feeds framework route detection — parity required. **PORTED + DEFAULT-ON (§4e).** | ✅ |
 | go | `languages/go.ts` | T1 | crates.io | Third (tie). Value-reference edges ship here too (#897). **PORTED + DEFAULT-ON (§4e).** | ✅ |
 | ruby | `languages/ruby.ts` | T1 | crates.io | **DONE (R7b #3, 2026-07-20)** — `ruby.rs` walker; grammar bumped to v0.23.1 (crate + vendored wasm together; content bump, ABI stays 14; standalone gate: old-vs-new dumps byte-identical on sinatra/jekyll, rails = exactly the one classified `&.!=` misparse-fix hunk). Parity 0-diff on sinatra/jekyll/rails (3,763 files, 0 deferrals) + dump byte-identical ×3. Introduced the **v2 ref-flag wire slot** (REF_FLAG_FILE_PATH): the visitNode hook's mixin `implements` refs carry `filePath: ctx.filePath` — the one extraction-ref denormalized field; php's trait-use refs need the same bit. Quirk list: docs/design/ruby-kernel-port-checklist.md. | ☑ |
-| php | `languages/php.ts` | T1 | crates.io | PHP property-receiver shapes (#1220/#1251) are RESOLUTION-side, unaffected. Trait-use `implements` refs carry filePath → use REF_FLAG_FILE_PATH (shipped with ruby). Grammar bump to 0.24.2 required (NOT graph-neutral — see the php checklist's classified delta). | ☐ |
+| php | `languages/php.ts` | T1 | crates.io | **DONE (R7b #4, 2026-07-20)** — `php.rs` walker (LANGUAGE_PHP, never PHP_ONLY); grammar bumped to v0.24.2 (crate + vendored wasm together; NOT graph-neutral — bump gate = enumerate+classify: anon-class wrapper, grouped nested-clause skip, old-error files, the survey-missed 8.4 `new X()->m()` misparse fix, everything else proven resolution ripple via ref↔edge pairing). Parity 0-diff monolog/laravel-framework/symfony (13,950 files) + dump byte-identical ×3. Trait-use implements refs ride REF_FLAG_FILE_PATH. Quirk list: docs/design/php-kernel-port-checklist.md. | ☑ |
 | csharp | `languages/csharp.ts` | T1 | crates.io | **DONE (R7b #2, 2026-07-20)** — `csharp.rs` walker; NO grammar bump (the #717 vendored wasm verified table-identical to crate 0.23.5 — first port with no grammar-prep step); the #237 `#if` preParse stays TS-side via the route-point hoist. Parity 0-diff on serilog/Newtonsoft.Json/jellyfin (3,229 files) + dump byte-identical ×3; deferral 0.05–3.3% = both-arm `#if` damage. Quirk list: docs/design/csharp-kernel-port-checklist.md. | ☑ |
 | rust | `languages/rust.ts` | T1 | crates.io | **DONE (R7b #1, 2026-07-20)** — `rustlang.rs` walker; grammar bumped to v0.24.2 (crate + vendored wasm together). Parity 0-diff on ripgrep/tokio/rust-analyzer + dump byte-identical ×3; rust-analyzer's parser crates defer 18% (token-macro tables, both-arm parse errors — grammar-inherent). Quirk list: docs/design/rust-lang-kernel-port-checklist.md. | ☑ |
 | dart, scala, lua, luau, r | dedicated files | T1 | crates.io (luau/r/scala: verify crate freshness vs our wasm) | Long-tail T1; port opportunistically after the big five. | ☐ |

+ 2 - 1
scripts/kernel-parity.mjs

@@ -48,7 +48,7 @@ if (paths.length === 0) {
   process.exit(2);
 }
 
-const KERNEL_LANGS = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', 'c', 'cpp', 'rust', 'csharp', 'ruby']);
+const KERNEL_LANGS = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', 'c', 'cpp', 'rust', 'csharp', 'ruby', 'php']);
 const EXTS = new Map([
   ['.ts', 'typescript'], ['.mts', 'typescript'], ['.cts', 'typescript'],
   ['.tsx', 'tsx'], ['.js', 'javascript'], ['.mjs', 'javascript'],
@@ -61,6 +61,7 @@ const EXTS = new Map([
   ['.rs', 'rust'], // R7b
   ['.cs', 'csharp'], // R7b
   ['.rb', 'ruby'], ['.rake', 'ruby'], // R7b
+  ['.php', 'php'], ['.module', 'php'], ['.install', 'php'], ['.theme', 'php'], ['.inc', 'php'], // R7b
 ]);
 
 /** Collect candidate files. */

+ 7 - 0
src/extraction/grammars.ts

@@ -306,6 +306,13 @@ const VENDORED_WASM_LANGS: ReadonlySet<GrammarLanguage> = new Set([
   // ^0.20.1 tree-sitter-wasms build. Content bump only — the tag's checked-in
   // parser.c is still ABI 14 (predates the ABI-15 generator).
   'ruby',
+  // R7b (PHP kernel port prep): tree-sitter-php v0.24.2 (5b5627f), the FULL
+  // `php` grammar variant (HTML interleaving — php_only errors on leading
+  // HTML), built from the tag's checked-in php/src/parser.c + scanner.c
+  // (+ shared common/scanner.h), all sha-matched against the crates.io
+  // tarball. Replaces the ^0.22 tree-sitter-wasms build (ABI 14 → 15). NOT
+  // graph-neutral — the classified delta list lives in the php checklist doc.
+  'php',
 ]);
 
 /** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */

+ 4 - 0
src/extraction/kernel/index.ts

@@ -64,6 +64,10 @@ const DEFAULT_ROUTED: ReadonlySet<Language> = new Set<Language>([
   // full-init dump-diffs byte-identical ×3. Any deferral on a ruby sweep is
   // a walker-bug signal, not grammar reality.
   'ruby',
+  // R7b (2026-07-20): parity swept 0-diff on monolog/laravel-framework/
+  // symfony (13,950 files byte-parity) + full-init dump-diffs byte-identical
+  // ×3. Deferral ≈0–0.1% (genuinely-broken fixtures) — default sweep guard.
+  'php',
 ]);
 
 /**

BIN
src/extraction/wasm/tree-sitter-php.wasm