From f68067565744c052f81463ccabee122b808f0c43 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 8 Sep 2026 14:34:00 +0000 Subject: [PATCH 1/2] fix(php): bind namespaced receivers in ::class and constructor position PHP writes every namespaced name as a `qualified_name` node. Two extractor paths only accepted the simple forms, so a class referenced through a namespace was invisible to the graph: - extractStaticMemberRef accepted `identifier | type_identifier | simple_identifier | name | scoped_type_identifier`, none of which a namespaced receiver produces. `Foo\Bar::class`, `\App\Models\User::TABLE` and the alias form `Type\Bankverbindung::class` were dropped with no edge and no unresolved ref, so `impact` reported the class had no consumers. - extractInstantiation strips a `.` or `::` qualifier but not PHP's `\`, so `new Foo\Bar()` pushed the unresolvable literal `Foo\Bar`. Both now match on the trailing simple name, which is what walkPhpTypePosition already does for type hints and what the class node is stored as. The instantiation strip is scoped to PHP because a backslash carries no qualifier meaning in the other languages sharing that path. Measured on a 327-file PHP tree: `references` edges 426 -> 728, with node count and every other edge kind unchanged. `impact` on a class used only through an alias goes from 3 of 6 consumer files to 6 of 6. The alias is made moot rather than resolved: `Type\Bankverbindung` and `\App\SoapTypes\Bankverbindung` both reduce to `Bankverbindung`, so a same-named class in another namespace stays ambiguous here, exactly as it is for a type hint today. codegraph-kernel/src/php.rs carries the same two defects and is unchanged, so kernel-php-parity will diverge once a kernel binary is staged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014gmQvNnfH9URudZJeJU4ck --- .../php-qualified-static-member-refs.test.ts | 111 ++++++++++++++++++ src/extraction/tree-sitter.ts | 18 ++- 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 __tests__/php-qualified-static-member-refs.test.ts diff --git a/__tests__/php-qualified-static-member-refs.test.ts b/__tests__/php-qualified-static-member-refs.test.ts new file mode 100644 index 000000000..53431738a --- /dev/null +++ b/__tests__/php-qualified-static-member-refs.test.ts @@ -0,0 +1,111 @@ +/** + * PHP namespaced receivers in static-member and constructor position. + * + * `Foo\Bar::class` and `Foo\Bar::CONST` reach extractStaticMemberRef as a + * `class_constant_access_expression` whose receiver is a `qualified_name` — + * the node kind PHP uses for EVERY namespaced name, whether it came from an + * import alias (`use App\SoapTypes as Type;` → `Type\Bankverbindung::class`), + * a fully-qualified path (`\App\SoapTypes\Bankverbindung::class`) or a + * namespace-relative one (`SoapTypes\Bankverbindung::class`). A bare + * `Bankverbindung::class` is a `name` and was always handled; the qualified + * forms produced no edge AND no unresolved ref, so a class referenced only + * that way looked like nothing depended on it. + * + * `new Foo\Bar()` is the same gap one function over: extractInstantiation + * strips a `.` or `::` qualifier but not PHP's `\`, so the ref was pushed as + * the unresolvable literal `Foo\Bar`. + * + * Both now match on the trailing simple name, which is what walkPhpTypePosition + * already does for type hints and what the class node is stored as. That makes + * the alias moot rather than resolved — `Type\Bankverbindung` and + * `\App\SoapTypes\Bankverbindung` both reduce to `Bankverbindung` — so a + * same-named class in another namespace stays ambiguous here, exactly as it is + * for a type hint. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('PHP qualified static-member and constructor refs', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-qual-recv-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + /** Every non-`contains` edge as ` -> `. */ + const load = async (): Promise => { + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const rows: { kind: string; src: string; tgt: string }[] = db + .prepare( + `SELECT e.kind kind, s.name src, t.name tgt + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind IN ('references', 'instantiates')`, + ) + .all(); + cg.close?.(); + return rows.map((r) => `${r.kind} ${r.src} -> ${r.tgt}`); + }; + + const types = ` ` { + // Mutation: drop the `qualified_name` branch in extractStaticMemberRef. + write('src/Types.php', types); + write('src/Consumer.php', consumer(' public function aliased() { return Type\\Bankverbindung::class; }')); + expect(await load()).toContain('references aliased -> Bankverbindung'); + }); + + it('binds a fully-qualified receiver in ::class position', async () => { + // Mutation: as above — a leading `\` is the same qualified_name node. + write('src/Types.php', types); + write('src/Consumer.php', consumer(' public function fq() { return \\Vendor\\SoapTypes\\Bankverbindung::class; }')); + expect(await load()).toContain('references fq -> Bankverbindung'); + }); + + it('binds a namespace-relative receiver in ::class position', async () => { + // Mutation: as above — no import needed for the branch to fire. + write('src/Types.php', types); + write('src/Consumer.php', consumer(' public function rel() { return SoapTypes\\Bankverbindung::class; }')); + expect(await load()).toContain('references rel -> Bankverbindung'); + }); + + it('binds a qualified constructor', async () => { + // Mutation: remove `className.lastIndexOf('\\')` from extractInstantiation's + // qualifier strip — the ref is then pushed as the literal `Type\Bankverbindung` + // and resolves to nothing. + write('src/Types.php', types); + write('src/Consumer.php', consumer(' public function make() { return new Type\\Bankverbindung(); }')); + expect(await load()).toContain('instantiates make -> Bankverbindung'); + }); + + it('leaves a lowercase-headed qualified receiver alone', async () => { + // Mutation: drop the /^[A-Z]/ test in the new branch. `$conn::TIMEOUT` on a + // namespaced variable is not a type reference, and emitting one would let + // bare-name matching bind it to an unrelated same-named symbol. + write('src/Types.php', types); + write('src/Consumer.php', consumer(' public function low() { return config\\bankverbindung::TIMEOUT; }')); + expect(await load()).not.toContain('references low -> Bankverbindung'); + }); +}); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index a57ec89a3..454c91b02 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4876,10 +4876,13 @@ export class TreeSitterExtractor { } // For namespaced/qualified constructors (`new ns.Foo()`, // `new ns::Foo()`) keep the trailing identifier — that's what - // matches a class node in the index. + // matches a class node in the index. PHP spells the separator `\` + // (`new \App\Models\User()`), so it belongs in the same strip; scoped to + // php because a backslash carries no qualifier meaning in the others. const lastDot = Math.max( className.lastIndexOf('.'), - className.lastIndexOf('::') + className.lastIndexOf('::'), + this.language === 'php' ? className.lastIndexOf('\\') : -1 ); if (lastDot >= 0) className = className.slice(lastDot + 1).replace(/^[:.]/, ''); className = className.trim(); @@ -4987,6 +4990,17 @@ export class TreeSitterExtractor { node.namedChild(0); if (!recv) return; const t = recv.type; + // PHP writes any namespaced receiver as a `qualified_name` — `Foo\Bar::class`, + // `\App\Models\User::TABLE`, and the alias form `Type\Bankverbindung::class` + // after `use App\SoapTypes as Type;`. Without this branch every one of them is + // dropped with no unresolved ref to show for it. Match on the trailing simple + // name, as walkPhpTypePosition already does — that is what the class node is + // stored as, and what a `use` import brings into scope. + if (this.language === 'php' && t === 'qualified_name') { + const last = getNodeText(recv, this.source).split('\\').pop() ?? ''; + if (/^[A-Z][A-Za-z0-9_]*$/.test(last)) this.pushStaticMemberRef(last, ownerId, recv); + return; + } if ( t === 'identifier' || t === 'type_identifier' || t === 'simple_identifier' || t === 'name' || t === 'scoped_type_identifier' From 5dbe2d3b06da1892c7dd3d3b3868280a633bc408 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 8 Sep 2026 15:37:01 +0000 Subject: [PATCH 2/2] fix(php/kernel): mirror the namespaced-receiver fix in the native walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit php.rs carried both defects the TypeScript walker did, so with php in DEFAULT_ROUTED a default build's output was unchanged by the previous commit. - extract_static_member_ref repeated the same receiver-kind list, dropping a `qualified_name` receiver: `Foo\Bar::class`, `\App\Models\User::TABLE`, and the alias form after `use X as Type;`. - strip_generic_and_qualifier split on `.` and `::` but not `\`, so `new \App\Models\User()` kept the whole qualified text. Both edits are local to php.rs and need no language gate: the file is PHP-only and it carries its own private copy of strip_generic_and_qualifier rather than sharing one. kernel-php-parity needed no fixture change — it compares the two walkers against each other at runtime rather than against goldens, so it goes green once both agree. Its docstring described the old instantiation shape and is updated to match. Verified with the kernel staged, which is also what routes php through it: kernel-php-parity green (8 tests), and php-qualified-static-member-refs green (5 tests) now exercising the native path rather than the wasm fallback. Full suite 4,336 passed / 3 failed, those three (object-literal-methods, ui-steps-api x2) failing identically on clean main. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014gmQvNnfH9URudZJeJU4ck --- __tests__/kernel-php-parity.test.ts | 7 ++++--- codegraph-kernel/src/php.rs | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/__tests__/kernel-php-parity.test.ts b/__tests__/kernel-php-parity.test.ts index d95deb1c5..333e36ce7 100644 --- a/__tests__/kernel-php-parity.test.ts +++ b/__tests__/kernel-php-parity.test.ts @@ -13,9 +13,10 @@ * 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 + * (qualified reduced to its trailing name, `new static/self/parent` + * literal, `$cls`, the anonymous-class garbage ref + file-level-function + * methods), static value reads including namespaced `Foo\Bar::class` + * receivers, 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 diff --git a/codegraph-kernel/src/php.rs b/codegraph-kernel/src/php.rs index 13a8626b6..66c8e62c9 100644 --- a/codegraph-kernel/src/php.rs +++ b/codegraph-kernel/src/php.rs @@ -1155,6 +1155,17 @@ impl<'t> Walker<'t> { .or_else(|| node.child_by_field_name("scope")) .or_else(|| node.named_child(0)); let Some(recv) = recv else { return }; + // A namespaced receiver is a `qualified_name` — `Foo\Bar::class`, + // `\App\Models\User::TABLE`, and the alias form after `use X as Type;`. + // Match on the trailing simple name, as the php type-position walk + // already does: that is what the class node is stored as. + if recv.kind() == "qualified_name" { + let last = self.text(recv).rsplit('\\').next().unwrap_or(""); + if capitalized_re().is_match(last) { + self.push_ref_at(owner, &last.to_string(), edge_kind_index("references").unwrap(), recv); + } + return; + } if matches!( recv.kind(), "identifier" | "type_identifier" | "simple_identifier" | "name" | "scoped_type_identifier" @@ -1504,9 +1515,9 @@ fn find_anonymous_class_body(node: Node) -> Option { } /// The shared `new ns.Foo()` 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. +/// `<` (index > 0), keep the segment after the last `.`/`::`/`\`, strip ONE +/// leading `:` or `.`, trim. The backslash is php's own separator, so +/// `new \App\Models\User()` reduces to the name the class node carries. fn strip_generic_and_qualifier(raw: &str) -> String { let mut name = raw.to_string(); if let Some(lt) = name.find('<') { @@ -1518,7 +1529,8 @@ fn strip_generic_and_qualifier(raw: &str) -> String { .rfind('.') .map(|i| i as isize) .unwrap_or(-1) - .max(name.rfind("::").map(|i| i as isize).unwrap_or(-1)); + .max(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('.') {