fix(php): bind use-function imports outside PSR-4 by fully qualified name

`use function App\Mod\make; $x = make(); $x->m()` typed nothing, and the
call itself had no CALLS edge, when `make` is declared outside any PSR-4
path: loaded through composer `autoload.files`, or in a project with no
composer.json. Import resolution finds a function only through a PSR-4
directory or a file named after it, so the import bound nothing (or, with a
PSR-4 directory that holds other files, resolved to one that declares no
`make`). In the new fixture, `namedCall` even picked up another `make`'s
return type, mirrored by name from a sibling file, and emitted a wrong
`QffGadget.ping` edge.

After namespaces are known, a `use function A\f` whose module scope binds
nothing for its local name is bound to the one workspace function named
`A\f`, through the same per-pass FQN index as qualified callees. The fully
qualified name is the function's identity, so this guesses nothing; a
resolved import or a local declaration is left alone, and two declarations
of one name bind nothing. The IMPORTS edge is unchanged: finalize has no
namespaces yet.

Lang-resolution A/B against the parent: one wrong edge removed
(`namedCall -> QffGadget.ping`), two correct fixture edges added
(`namedCall -> make`, `namedCall -> QffWidget.ping`), and four correct
edges the combined corpus lacked because its merged composer configs map
`App\` to many fixtures: php-calls `create_user -> write_audit`,
php-overload-dispatch `run -> format_text` and `run -> format_text_padded`,
php-use-function-const `Calculator.process -> formatName`. Each FQN is
declared once in the corpus. Drops are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-10-21 05:06:36 +00:00
parent 1f6aa8e990
commit 4781ace51a
5 changed files with 93 additions and 5 deletions

View file

@ -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 {

View file

@ -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<ScopeId, Map<string, BindingRef[]>>;
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(

View file

@ -0,0 +1,12 @@
<?php
namespace Qff\App;
// `make` lives in lib/functions.php (composer `autoload.files`), so no PSR-4
// path declares it: only its fully qualified name finds it.
use function Qff\Mod\make;
function namedCall(): int {
$x = make();
return $x->ping();
}

View file

@ -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"

View file

@ -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')