diff --git a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts index 56ceb68a9..a05b8572c 100644 --- a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts @@ -28,7 +28,7 @@ import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { getPhpParser } from './query.js'; -import { recordPhpFunctionIndex } from './qualified-function.js'; +import { bindPhpUseFunctionImports, recordPhpFunctionIndex } from './qualified-function.js'; // ─── PHP file structure extraction ────────────────────────────────────────── @@ -222,9 +222,11 @@ export function populatePhpNamespaceSiblings( structureByFile.set(parsed.filePath, struct); namespaceByFilePath.set(parsed.filePath, struct.namespace); } - // Qualified function callees (`rp\make()`) resolve by fully qualified name, - // which needs every file's namespace and `use` table (#3498). + // Qualified function callees (`rp\make()`) and `use function` imports no + // PSR-4 path resolves find their function by fully qualified name, which + // needs every file's namespace and `use` table (#3498). recordPhpFunctionIndex(parsedFiles, indexes, namespaceByFilePath); + bindPhpUseFunctionImports(parsedFiles, indexes); // Step 2: group class-like defs and module scopes by namespace. interface NamespaceBucket { diff --git a/gitnexus/src/core/ingestion/languages/php/qualified-function.ts b/gitnexus/src/core/ingestion/languages/php/qualified-function.ts index 91710d3f5..504ffbadc 100644 --- a/gitnexus/src/core/ingestion/languages/php/qualified-function.ts +++ b/gitnexus/src/core/ingestion/languages/php/qualified-function.ts @@ -18,7 +18,13 @@ * shared scope-chain lookups already own it. */ -import type { ParsedFile, ParsedImport, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { + BindingRef, + ParsedFile, + ParsedImport, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; /** What PHP name resolution reads off the calling file. */ @@ -129,10 +135,59 @@ export function resolvePhpQualifiedCallee( const context = index.contextByFile.get(filePath); if (context === undefined) return undefined; const fqn = phpFunctionFqn(calleeText, context); - const defs = fqn === undefined ? undefined : index.functionsByFqn.get(fqn.toLowerCase()); + return fqn === undefined ? undefined : uniqueFunction(fqn, index); +} + +/** The one function declaring `fqn`, or `undefined` when none or several do. */ +function uniqueFunction(fqn: string, index: PhpFunctionIndex): SymbolDefinition | undefined { + const defs = index.functionsByFqn.get(fqn.toLowerCase()); return defs?.length === 1 ? defs[0] : undefined; } +/** + * Bind a `use function A\f;` that import resolution left without a binding to + * the one workspace function named `A\f`. Import resolution finds a function + * only through a PSR-4 directory or a file named after it, so one loaded + * through composer `autoload.files`, or declared in a project with no + * `composer.json`, binds nothing, and `$x = f()` has no return type to read. + * The fully qualified name is the function's identity, so this guesses + * nothing. A name the importing scope already binds is left alone: a resolved + * import or a local declaration is never second-guessed. + */ +export function bindPhpUseFunctionImports( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): void { + const index = indexByPass.get(indexes); + if (index === undefined) return; + const augmentations = indexes.bindingAugmentations as Map>; + for (const parsed of parsedFiles) { + if (!index.contextByFile.has(parsed.filePath)) continue; + for (const parsedImport of parsed.parsedImports) { + if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue; + if (parsedImport.importedSymbolKind !== 'function') continue; + const def = uniqueFunction(parsedImport.targetRaw.replace(/^\\+/, ''), index); + if (def === undefined) continue; + const name = parsedImport.localName; + // Finalize attaches PHP imports, and binds them, at the module scope. + const scopeId = parsed.moduleScope; + const via = indexes.imports.get(scopeId)?.find((edge) => edge.localName === name); + if (via === undefined) continue; + const bound = + (indexes.scopeTree.getScope(scopeId)?.bindings.get(name)?.length ?? 0) + + (indexes.bindings.get(scopeId)?.get(name)?.length ?? 0) + + (augmentations.get(scopeId)?.get(name)?.length ?? 0); + if (bound > 0) continue; + let scopeBindings = augmentations.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + augmentations.set(scopeId, scopeBindings); + } + scopeBindings.set(name, [{ def, origin: 'import', via }]); + } + } +} + /** `ScopeResolver.resolveQualifiedFreeCall`: the same resolution, for the * call site the qualified callee itself mints. */ export function resolvePhpQualifiedFreeCall( diff --git a/gitnexus/test/fixtures/lang-resolution/php-qualified-function-factory/src/App/Named.php b/gitnexus/test/fixtures/lang-resolution/php-qualified-function-factory/src/App/Named.php new file mode 100644 index 000000000..179287e6c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-qualified-function-factory/src/App/Named.php @@ -0,0 +1,12 @@ +ping(); +} diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index 0f90752eb..95c99418b 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -431,6 +431,10 @@ "captureGroups": 36, "digest": "19846b0438c564d2931da26ac4ca002ef4f4b20862cd66ac17ad75174f8d4ffa" }, + "php-qualified-function-factory/src/App/Named.php": { + "captureGroups": 8, + "digest": "7ebd5c95e0428ded85e86c0bc03c94f423df1f2dbcb7d697704be6c1bc51f7fc" + }, "php-qualified-function-factory/src/Entry.php": { "captureGroups": 7, "digest": "14195813d6af5373c5a1d93ba0779b9958546f2b8d4090089fd43effe4871952" diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index ed830b7ce..b892d9401 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -2377,6 +2377,21 @@ describe('PHP namespaced-function factory result types (#3498 follow-up)', () => ]); }); + it('types a use-function import outside any PSR-4 path from the function it names', () => { + expect(callsFrom('namedCall', 'ping')).toEqual([ + 'Method:src/Mod/QffWidget.php:QffWidget.ping#0', + ]); + }); + + it('resolves a use-function import outside any PSR-4 path by its fully qualified name', () => { + const edges = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'namedCall' && edge.target === 'make', + ); + expect(edges.map((edge) => [edge.rel.targetId, edge.rel.reason])).toEqual([ + ['Function:lib/functions.php:make', 'import-resolved'], + ]); + }); + it('records the untyped receivers as dropped sites instead of an exact miss', () => { const dropped = getResolutionOutcomes(result) .filter((outcome) => outcome.kind === 'suppressed')