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/__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/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('.') { 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'