A method called through a PHP fluent static factory — `ApiClient::for($c)->createOrder()`, the canonical Laravel per-credential/per-tenant client idiom — produced no `calls` edge: the receiver of `->createOrder` is the `Cls::for(...)` static call, whose result type was never recovered, so the edge was dropped and `codegraph_callers` returned nothing. Same shape as the C++ singleton/factory fix (#645), reusing its return_type column + the chained-call mechanism: - Capture PHP return types (getReturnType): `: self` / `: static` / `$this` stored as the `self` marker, a concrete `: Type` as its short name, primitives/unions dropped. - Encode the chained scoped-call receiver as `Cls::for().method` so the resolver can split it (PHP-gated, in extractCall). - New matchPhpCallChain: look up the factory's return type (`self` → the factory's own class; concrete → that class), then resolve AND validate the method on it — a wrong inference yields no edge, never a wrong one. EXTRACTION_VERSION 4->5 (re-index to populate PHP return types + chained edges). Validated on koel (1383 PHP files): node count identical (no explosion), 0 edges lost, +80 chained-call edges recovered; synthetic tests cover the self-factory, concrete-return, namespace, decoy, and absent-method cases. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
75ae1e8bd9
commit
eb5960b535
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user