Repository: colbymchenry/codegraph (2026-06-09T03:09:46Z)

Issue:
PHP: `codegraph_callers` misses chained calls on static-factory return values (`Cls::for($x)->method(...)`) — common Laravel per-credential client pattern
## Summary

On PHP, a call made through a **chained static-factory expression** — `Cls::for($x)->method(...)` where `for()` is a static factory returning `: self` — produces **no `calls` edge**, so `codegraph_callers` returns empty for the target method. Both the caller node and the callee node are correctly indexed via `codegraph_search`; only the edge between them is dropped.

This is the **same root cause class** confirmed and fixed for Go in #388 / #469 (`isExternalImport` → `resolveViaImport` fall-through), still open for Python in #578 (`module.func(...)` after `from pkg import module`), and analogous to the per-language extractor fixes already shipped for C# (#381), C++ (#445), and Java/Kotlin (#314, #487). The PHP chain-expression case wasn't covered. Filing now since this pattern is the canonical Laravel idiom for per-credential or per-tenant client binding (every Laravel app that integrates with more than one external API or tenant tends to use it), so the gap is noticeable on any real-world Laravel codebase.

## Environment

- `@colbymchenry/codegraph@0.9.8` (latest at time of filing)
- Windows 11 / PowerShell (CLI invocation)
- Project: Laravel 13 + Inertia/React middleware, PHP 8.3, 723 indexed files, 10,380 nodes, 21,363 edges, 27.59 MB DB
- Index freshly rebuilt via `codegraph init -i` immediately before querying
- `codegraph status` reports `[OK] Index is up to date`

## Minimal reproduction

```
repro/
  src/
    ApiClient.php
    DispatchOrder.php
```

`src/ApiClient.php`:
```php
<?php
class ApiClient {
    public static function for(string $credential): self {
        return new self;
    }

    public function createOrder(array $payload): array {
        return [];
    }
}
```

`src/DispatchOrder.php`:
```php
<?php
class DispatchOrder {
    public function handle(): void {
        $response = ApiClient::for('cred-123')->createOrder([]);   // <- edge dropped
    }
}
```

**Expected:** `codegraph callers "ApiClient::createOrder"` → 1 caller (`DispatchOrder::handle`).
**Observed:** `No callers found for "ApiClient::createOrder"`.

Also tried, all returning zero:
- `codegraph callers "createOrder"` (bare name)
- `codegraph callers "\ApiClient::createOrder"` (leading-slash FQN)
- `codegraph callers "App\ApiClient::createOrder"` (namespaced FQN)

## Behavior matrix — what works vs what doesn't

| Call form | Caller code | Edge recorded? | `callers <FQN>` | `callers <bare>` |
|---|---|---|---|---|
| Chained static-factory → method | `Cls::for($x)->method(...)` | ❌ no edge | 0 | 0 |
| Two-line via variable | `$c = Cls::for($x); $c->method(...);` | ✅ recorded **unqualified** | 0 | finds it |
| Instance call on `$this` | `$this->method(...)` | ✅ qualified | finds it | finds it |
| True static call | `Cls::method(...)` (no chain) | ✅ qualified | finds it | finds it |

So the gap is specifically the **chained / fluent shape** where the receiver type would need to be derived from the previous method's return type. The factory methods all have explicit `: self` return types — adding type annotations does not change behavior, so the issue is in chain-expression resolution rather than annotation reading.

Comparison in the forward direction: `codegraph callees "DispatchOrder::handle"` correctly returns `for` and `createOrder` as callee symbols. Enumerating method names from the syntax tree is straightforward; the reverse direction is where the qualifier is lost.

## Real-world production evidence (this is not a toy-only failure)

In a real Laravel 13 middleware codebase that integrates with multiple third-party SaaS APIs, the canonical per-credential client binding looks like:

```php
public static function for(ApiCredential $credential): self { /* ... */ }
```

…called everywhere as `XxxClient::for($cred)->methodName(...)` so each callsite binds the credential at the call site without rebinding the DI container. This is the standard Laravel pattern for "I have N tenant/integration credentials and I want a typed client per credential" — not specific to this project.

In that index:
- **27 chained callsites** across **18 files** (covering 5 distinct `*Client` services, each with its own `for(): self` factory)
- For a representative method `OrderClient::createOrder` invoked by exactly one caller (`DispatchOrder::handle`), via the chain form:
  - `codegraph callers "OrderClient::createOrder"` → **0** (textual grep `'->createOrder('` finds 1 real caller)
  - `codegraph callers "createOrder"` (bare) → **0**
  - `codegraph callers "App\\Services\\OrderClient::createOrder"` (FQN) → **0**

`codegraph impact` does still surface the affected method's downstream files (179 affected symbols for the same target, which includes the real caller), so the **blast-radius view** still works as a fallback. But it traverses class-level reachability and over-reports rather than recording the precise call edge — useful for "what could break?" but not for "who calls this?".

**Real-world impact:** for a Laravel app, the question "who calls `Client::method`?" is one of the most common reasons to reach for a code graph — refactor planning, dead-code analysis, etc. With this gap, the answer for chained-factory clients is "Grep is more reliable for now," which works but undermines the value proposition for one of the canonical Laravel patterns.

## Hypothesis

Parallel to the #469 Go root cause and the #578 Python hypothesis: the PHP extractor processes the method-call node, but the receiver of the call is a `member_call_expression` whose own receiver is a `scoped_call_expression` (the `Cls::for($x)` static factory). Resolving the type of that intermediate result would require following the static method's `: self` return type one hop — which doesn't appear to be done in the current extraction pass, so the receiver class resolves to null and the call edge is silently dropped (consistent with the clean ~0% recall rather than partial recall).

Failure mode (pseudocode), assuming `dist/extraction/languages/php` follows the same shape as the Go branch:

```ts
function resolveCallReceiver(node: TSNode, ctx: ExtractContext): ResolvedClass | null {
  // node is the `->createOrder` member-call expression
  const receiver = node.children.find(n => n.type === 'object')   // → `Cls::for($x)` scoped_call
  if (receiver.type === 'scoped_call_expression') {
    // We'd need to look up the static method `Cls::for` and read its
    // return type — `: self` resolves back to Cls — but this lookup
    // doesn't appear to happen, so receiverClass stays null
    // and the call edge is dropped silently.
    return null
  }
  // other receiver shapes (variables, $this, true static) work fine
}
```

A Laravel-aware shortcut also exists: `dist/resolution/frameworks/laravel.d.ts` already covers facade mappings. A static-factory resolver branch in that file (PHP-side analog of `src/resolution/go-module.ts`) could short-circuit the common Laravel pattern with minimal scope creep, even if generic chain-expression type resolution feels too broad for the PHP extractor.

## Ask

1. Can the PHP extractor / resolver get the same treatment Go received in #469 — for a `scoped_call_expression` whose called static method has a self-returning declared type (`: self` / `: static`), resolve the chained `->method(...)` to the named callee on that class and emit the `calls` edge?
2. If a generic chain resolver is too broad, a Laravel-specific resolver for `static function for(...): self` factories (the canonical per-credential client pattern) would cover the most common real-world incidence without committing to full PHP type-flow.
3. Happy to run targeted queries against the local `.codegraph/codegraph.db` (e.g. dump `edges` joining to `nodes` for a specific file) to validate hypotheses or any candidate fix.
4. If a minimal anonymized repro repo would help reproduce the failure outside Laravel proper, glad to put one together.

Not blocking on our end — we've already adjusted our agent workflow to use `grep '->method('` for "who calls this?" questions on `*Client::*` methods and reserve `codegraph callers` for instance / true-static / non-chained call sites. Filing so the gap is at least tracked alongside the Python and prior Go reports.

## Related

- #578 — Python: `module.func(...)` after `from pkg import module` (same root cause class, open)
- #388 — Go: `pkg.Func(...)` cross-package qualified calls (same root cause class, fixed in #469 / `f1b79ee`)
- #381 — C#: missing reference edges (per-language extractor fix, fixed)
- #445 — C++: member-pointer method calls via `->` (per-language extractor fix, fixed)
- #487 — Java/Kotlin: lambda → SAM-functional-interface synthesis (per-language extractor fix, fixed)
- #314 — Java import resolution (per-language resolver fix, fixed)



Discussion:
Fixed in #749 (merged to `main`) — thanks for the exemplary report: the behavior matrix, the root-cause hypothesis, and the real-world Laravel evidence all landed exactly where the fix needed to go.

Your hypothesis was spot on — the receiver of `->createOrder` is the `Cls::for(...)` static call, and its result type was never recovered, so the edge dropped. It's the same shape as the C++ singleton/factory fix (#645), so this reuses that machinery:

1. PHP return types are now captured — `: self` / `: static` resolve to the declaring class, `: SomeClass` to that class.
2. The chained receiver `Cls::for($x)` is preserved through extraction instead of degrading to an unresolvable string.
3. A new resolver looks up the factory's return type and resolves `->method()` on it — **and validates** the method exists on that class, so a wrong inference produces no edge rather than a wrong one.

So `ApiClient::for($cred)->createOrder()` now records `DispatchOrder::handle → ApiClient::createOrder`, and `codegraph_callers "ApiClient::createOrder"` returns the chained caller.

Validated on koel (1383 PHP files): node count unchanged (no explosion), nothing lost, **+80 chained-call edges recovered** — plus synthetic coverage for the `: self` and concrete-return-type factories, namespaced classes, and the safety cases (a same-named method on an unrelated class, and an absent method, both correctly get no edge).

Scope note matching your report: the two-line variable form (`$c = Cls::for($x); $c->method()`) already resolves in current builds, and this fix targets the chained/fluent shape specifically. Ships in the next release; re-index to pick up PHP return types + the new edges.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f73044cd..c5c47ed32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- PHP: a method called through a chained static factory — `Cls::for($x)->method(...)`, the canonical Laravel per-credential / per-tenant client idiom — now records a caller edge. Previously the receiver type (what `for()` returns) was never recovered, so `codegraph_callers` returned nothing for the method and the call was invisible to `codegraph_impact`. CodeGraph now captures PHP return types — `: self` / `: static` resolve to the declaring class, `: SomeClass` to that class — and resolves the chained method on the factory's result, creating the edge only when that class actually has the method (so a wrong inference produces no edge). Existing PHP indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @cvanderlinden. (#608) (PHP)
 - Search relevance: including the project name in a query (a user naturally writes `MyApp backend routes`) no longer buries the part of the codebase the query is actually about. The project name lexically matches whatever stack embeds it — a `MyAppFrontend/` directory, a `MyAppApp` class — and it was over-weighted two ways: a single PascalCase word was scored once per sub-token (`my` / `app` / `myapp`), so one concept boosted that path several times over; and the name carried full path / disambiguation weight even though it names the whole repo, not any symbol. Now path relevance counts each query word once, and a word matching the project name (derived from `go.mod`, `package.json`, or the repo directory) is dropped from path scoring and from `codegraph_explore`'s type-disambiguation bias — unless it's the only term, so a bare project-name search still works. In a mixed-stack repo, a backend question now surfaces the backend even with the project name in the query. Thanks @MiNuo1. (#720)
 - Go: a function called only from inside an anonymous closure — a cobra `RunE: func(…) {…}` handler, a goroutine literal, or a callback closure stored in a package-level `var` — now shows its real caller. Previously the call leaked to the file node, so `codegraph_callers` and `codegraph_impact` reported such a function as having no meaningful caller; the call is now attributed to the enclosing declaration, so editing the function surfaces the closures that use it. Existing Go indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @Cyclone1070. (#693) (Go)
 - Indexing no longer aborts when a `.gitignore` contains non-UTF-8 bytes or an unparseable pattern. A `.gitignore` transparently encrypted in place by corporate DLP / endpoint-security software (a common enterprise scenario) — or one with a stray pattern the matcher can't compile (`\[`, producing "Unterminated character class") — used to crash the entire `sync` / `index` with a screen of garbled bytes and never name the offending file, leaving `Files: 0 / Nodes: 0`. CodeGraph now skips a `.gitignore` that isn't valid UTF-8 text whole, drops only the individual unparseable patterns from a text one, and logs a warning naming the file — indexing continues either way. Thanks @zhanghang-9527. (#682)
diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts
index 9428a44ed..41143fe08 100644
--- a/src/extraction/extraction-version.ts
+++ b/src/extraction/extraction-version.ts
@@ -21,4 +21,4 @@
  * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
  * in the product is load-bearing").
  */
-export const EXTRACTION_VERSION = 4;
+export const EXTRACTION_VERSION = 5;
diff --git a/src/extraction/languages/php.ts b/src/extraction/languages/php.ts
index 673fbf90f..e6c1ce1b0 100644
--- a/src/extraction/languages/php.ts
+++ b/src/extraction/languages/php.ts
@@ -1,5 +1,5 @@
 import type { Node as SyntaxNode } from 'web-tree-sitter';
-import { getNodeText } from '../tree-sitter-helpers';
+import { getNodeText, getChildByField } from '../tree-sitter-helpers';
 import type { LanguageExtractor } from '../tree-sitter-types';
 
 // include / require (+ _once) expression node types. These carry the
@@ -33,6 +33,38 @@ function phpStaticIncludePath(node: SyntaxNode, source: string): string | null {
   return content ? getNodeText(content, source) : null;
 }
 
+/** PHP built-in return types that can't be a method receiver (so no class to chain on). */
+const PHP_NON_CLASS_RETURN = new Set([
+  'array', 'string', 'int', 'integer', 'float', 'double', 'bool', 'boolean',
+  'void', 'mixed', 'never', 'null', 'false', 'true', 'object', 'callable',
+  'iterable', 'resource',
+]);
+
+/**
+ * A method/function's declared return type, normalized to the class a chained
+ * `->method()` could be called on (issue #608). `self` / `static` / `$this` are
+ * kept as the marker `self` and resolved to the declaring class at resolution
+ * time; a concrete type returns its short name; primitives / unions / nullable
+ * non-class types return undefined.
+ */
+function extractPhpReturnType(node: SyntaxNode, source: string): string | undefined {
+  let rt = getChildByField(node, 'return_type');
+  if (!rt) return undefined;
+  // Unwrap `?Type`. Union / intersection types are ambiguous — skip them.
+  if (rt.type === 'optional_type') rt = rt.namedChild(0) ?? rt;
+  if (!rt || rt.type === 'primitive_type') return undefined;
+
+  const nameNode = rt.type === 'named_type' ? (rt.namedChild(0) ?? rt) : rt;
+  const text = getNodeText(nameNode, source).trim().replace(/^\\/, '');
+  if (!text) return undefined;
+  const last = text.split('\\').pop() ?? text;
+  const lc = last.toLowerCase();
+  if (lc === 'self' || lc === 'static' || lc === 'this' || lc === '$this') return 'self';
+  if (PHP_NON_CLASS_RETURN.has(lc)) return undefined;
+  if (!/^[A-Za-z_]\w*$/.test(last)) return undefined; // union/intersection/complex
+  return last;
+}
+
 export const phpExtractor: LanguageExtractor = {
   functionTypes: ['function_definition'],
   classTypes: ['class_declaration', 'trait_declaration'],
@@ -50,6 +82,7 @@ export const phpExtractor: LanguageExtractor = {
   bodyField: 'body',
   paramsField: 'parameters',
   returnField: 'return_type',
+  getReturnType: extractPhpReturnType,
   classifyClassNode: (node) => {
     return node.type === 'trait_declaration' ? 'trait' : 'class';
   },
diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts
index 798229489..61bd674b7 100644
--- a/src/extraction/tree-sitter.ts
+++ b/src/extraction/tree-sitter.ts
@@ -2349,6 +2349,33 @@ export class TreeSitterExtractor {
       // single-dot receiver regex fails. Pull out the immediate field after `this.`
       // so the receiver is the field name (`userbo`), which the resolver can then
       // look up in the enclosing class's field declarations.
+      // PHP static-factory fluent chain: `Cls::for($x)->method()` — the receiver
+      // is itself a static call, so resolution must infer the method's class
+      // from what `Cls::for` RETURNS (its `: self` / `: static` / `: Type`),
+      // #608 (mirrors the C++ chain fix in #645). Encode `<Cls::factory>().<method>`;
+      // the `().` marker lets the PHP resolver split it. The receiver text
+      // (`Cls::for('x')`) carries the args, so without this it degrades to an
+      // unresolvable string and the call edge is dropped.
+      if (methodName && this.language === 'php' && objectField.type === 'scoped_call_expression') {
+        const innerScope = getChildByField(objectField, 'scope');
+        const innerName = getChildByField(objectField, 'name');
+        if (innerScope && innerName) {
+          calleeName = `${getNodeText(innerScope, this.source)}::${getNodeText(innerName, this.source)}().${methodName}`;
+        } else {
+          calleeName = methodName;
+        }
+        if (calleeName) {
+          this.unresolvedReferences.push({
+            fromNodeId: callerId,
+            referenceName: calleeName,
+            referenceKind: 'calls',
+            line: node.startPosition.row + 1,
+            column: node.startPosition.column,
+          });
+        }
+        return;
+      }
+
       let receiverName: string;
       if (objectField.type === 'field_access') {
         const inner = getChildByField(objectField, 'object');
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index f01628c12..f283e6130 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -414,10 +414,11 @@ function cppLastSegment(name: string): string {
 
 /**
  * Return type captured at extraction for `Class::method` (or a free function),
- * read off the indexed node's `returnType` (#645). Null when not indexed or no
- * return type was recorded (e.g. a `void`/primitive return).
+ * read off the indexed node's `returnType` — used by the C++ (#645) and PHP
+ * (#608) chained-call resolvers. Language-filtered. Null when not indexed or no
+ * return type was recorded (a `void`/primitive return).
  */
-function lookupCppReturnType(
+function lookupCalleeReturnType(
   callee: string,
   ref: UnresolvedRef,
   context: ResolutionContext,
@@ -492,10 +493,10 @@ function resolveCppCallResultType(
     if (recv.includes('.') || recv.includes('(') || recv.includes('::')) return null; // single level only
     const recvType = inferCppReceiverType(recv, ref, context, depth + 1);
     if (!recvType) return null;
-    return lookupCppReturnType(`${recvType}::${method}`, ref, context);
+    return lookupCalleeReturnType(`${recvType}::${method}`, ref, context);
   }
 
-  const ret = lookupCppReturnType(expr, ref, context);
+  const ret = lookupCalleeReturnType(expr, ref, context);
   if (ret) return ret;
 
   // Direct construction — the callee itself names a class/struct.
@@ -549,6 +550,32 @@ export function matchCppCallChain(
   return resolveMethodOnType(cls, m[2], ref, context, 0.85, 'instance-method');
 }
 
+/**
+ * Resolve a PHP fluent static-factory chain whose receiver is a static call —
+ * `Cls::for($x)->method()`, encoded by the extractor as `Cls::for().method`
+ * (#608, the per-credential Laravel client idiom). The receiver's type is what
+ * `Cls::for` returns: a `: self` / `: static` resolves to `Cls` itself, a
+ * concrete `: Type` to that type. The outer method is then resolved and
+ * VALIDATED on it (resolveMethodOnType requires the method to exist), so a
+ * wrong inference yields no edge rather than a wrong one.
+ */
+export function matchPhpCallChain(
+  ref: UnresolvedRef,
+  context: ResolutionContext,
+): ResolvedRef | null {
+  const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
+  if (!m || !m[1] || !m[2]) return null;
+  const inner = m[1];
+  const method = m[2];
+  if (!inner.includes('::')) return null; // only static-factory (`Cls::method`) chains
+  const factoryClass = inner.slice(0, inner.lastIndexOf('::'));
+  const ret = lookupCalleeReturnType(inner, ref, context);
+  if (!ret) return null;
+  // `self` (the extractor's marker for self/static/$this) → the factory's class.
+  const resolvedClass = ret === 'self' ? factoryClass : ret;
+  return resolveMethodOnType(resolvedClass, method, ref, context, 0.85, 'instance-method');
+}
+
 /**
  * Java/Kotlin: infer a receiver's declared type by walking field declarations
  * in the class enclosing the call site. The field's `signature` is already in
@@ -971,6 +998,14 @@ export function matchReference(
     if (result) return result;
   }
 
+  // 1c. PHP fluent static-factory chain — `Cls::for($x)->method()` encoded as
+  // `Cls::for().method` (#608). Same idea as 1b: the receiver's type is the
+  // factory's `: self` / `: Type` return.
+  if (ref.language === 'php') {
+    result = matchPhpCallChain(ref, context);
+    if (result) return result;
+  }
+
   // 2. Method call pattern
   result = matchMethodCall(ref, context);
   if (result) return result;


Test patch:
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index e9231963b..aae1d9e62 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -2406,6 +2406,30 @@ end
     });
   });
 
+  describe('PHP return type capture (#608)', () => {
+    it('captures self/static factory returns as the `self` marker; primitives as undefined', () => {
+      const code = `<?php
+class ApiClient {
+    public static function for(string $c): self { return new self; }
+    public static function make(): static { return new static; }
+    public function send(array $p): array { return []; }
+}`;
+      const result = extractFromSource('ApiClient.php', code);
+      expect(result.nodes.find((n) => n.name === 'for' && n.kind === 'method')?.returnType).toBe('self');
+      expect(result.nodes.find((n) => n.name === 'make' && n.kind === 'method')?.returnType).toBe('self');
+      // `array` is not a class to chain on → no return type recorded.
+      expect(result.nodes.find((n) => n.name === 'send' && n.kind === 'method')?.returnType).toBeUndefined();
+    });
+
+    it('captures a concrete return type as its short class name', () => {
+      const code = `<?php
+namespace App;
+class WidgetFactory { public static function make(): Widget { return new Widget(); } }`;
+      const result = extractFromSource('WidgetFactory.php', code);
+      expect(result.nodes.find((n) => n.name === 'make' && n.kind === 'method')?.returnType).toBe('Widget');
+    });
+  });
+
   describe('C/C++ return type capture (#645)', () => {
     it('captures the normalized return type of a C++ method/function', () => {
       const code = `
diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts
index 1b97e05c9..e727b953c 100644
--- a/__tests__/resolution.test.ts
+++ b/__tests__/resolution.test.ts
@@ -2158,4 +2158,41 @@ void wrong() { WidgetFactory::create().onlyOther(); }
       expect(callerNamesOf('Other::onlyOther')).toEqual([]);
     });
   });
+
+  describe('PHP chained static-factory call resolution (#608)', () => {
+    function callerNamesOf(qualifiedName: string): string[] {
+      const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
+      if (!target) return [];
+      const names = cg
+        .getIncomingEdges(target.id)
+        .filter((e) => e.kind === 'calls')
+        .map((e) => cg.getNode(e.source)?.name)
+        .filter((n): n is string => !!n);
+      return [...new Set(names)].sort();
+    }
+
+    it('resolves Cls::for($x)->method() via the factory\'s `: self` return (#608)', async () => {
+      fs.writeFileSync(
+        path.join(tempDir, 'ApiClient.php'),
+        `<?php\nclass ApiClient {\n    public static function for(string $c): self { return new self; }\n    public function createOrder(array $p): array { return []; }\n}\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'DispatchOrder.php'),
+        `<?php\nclass DispatchOrder {\n    public function handle(): void {\n        ApiClient::for('cred')->createOrder([]);\n    }\n}\n`
+      );
+      cg = await CodeGraph.init(tempDir, { index: true });
+      // The chained call's edge attaches to the factory result's method.
+      expect(callerNamesOf('ApiClient::createOrder')).toContain('handle');
+    });
+
+    it('creates NO edge when the factory result lacks the method (#608)', async () => {
+      fs.writeFileSync(
+        path.join(tempDir, 'lib.php'),
+        `<?php\nclass ApiClient { public static function for(string $c): self { return new self; } }\nclass Other { public function onlyOther(): void {} }\nclass Caller { public function go(): void { ApiClient::for('x')->onlyOther(); } }\n`
+      );
+      cg = await CodeGraph.init(tempDir, { index: true });
+      // ApiClient has no onlyOther — must not mis-attach to the same-named Other::onlyOther.
+      expect(callerNamesOf('Other::onlyOther')).toEqual([]);
+    });
+  });
 });