mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-04 07:27:20 +00:00
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:
parent
1f6aa8e990
commit
4781ace51a
5 changed files with 93 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
12
gitnexus/test/fixtures/lang-resolution/php-qualified-function-factory/src/App/Named.php
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/php-qualified-function-factory/src/App/Named.php
vendored
Normal 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();
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue