GitNexus/gitnexus/test/unit/scope-resolution/php/php-qualified-function.test.ts
Abhigyan Patwari 36e16434ea
fix(php): type locals from namespaced-function factory calls (#3498) (#3662)
* fix(php): type locals from namespaced-function factory calls

`namespace App; use App\Mod as rp; $x = rp\make(); $x->m()` left `$x`
untyped although `make` is declared `: C` in `namespace App\Mod`, and the
inline `rp\make()->m()` recorded a drop. The query matched only an
unqualified callee (`function: (name)`), so a `qualified_name` callee minted
neither a call site nor a result binding; `\App\Mod\make()`, `Mod\make()`
and `namespace\make()` failed the same way, and nothing could have expanded
the path anyway: functions were never indexed by fully qualified name.

- A `qualified_name` / `relative_name` callee is captured as a free call
  (tail name, written path as `@reference.qualified-name`) and as a
  call-result binding that keeps the whole path, leading `\` included.
- New `php/qualified-function.ts` expands that path by PHP's name rules:
  `\` is absolute, `namespace\` is the file's namespace, and a qualified
  head goes through the file's class/namespace `use` table, else the file's
  namespace. It indexes every `Function` def by lower-cased FQN per pass and
  answers only when exactly one def declares the name.
- PHP implements `resolveQualifiedCallee` and `resolveQualifiedFreeCall`
  with that one resolution, so a call's type and its CALLS edge agree.
  `Qff\Decoy\make` in the fixture is never picked.
- A receiver bound to an in-program namespaced function with no return type
  (`$x = rp\untyped()`) now records a `receiver-unresolved` drop.

A full lang-resolution A/B against the parent removes no CALLS/ACCESSES edge
and no drop. It adds 20 edges, all inside the new
php-qualified-function-factory fixture, and 2 drops (its two untyped sites).
The PHP capture golden gains the new fixture only. Existing fixtures' digests
are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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>

* test(bench): rebaseline php captures and receiver counts

- receiver-resolution: the new php-qualified-function-factory fixture's two
  untyped sites (`$x = rp\untyped(); $x->ping()` and inline
  `rp\untyped()->ping()`) are each one call drop: call drops 154 -> 156,
  all kinds 224 -> 226. No other drop moves in a same-corpus A/B, and
  shape-arm cells are unchanged.
- scope-capture (php): corpus growth only. The query now captures
  qualified function callees, but no existing fixture has one: parking the
  new fixture reproduces the prior fingerprint byte-for-byte. It adds 96
  capture groups and 9 fixture entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(php): fail closed on qualified calls the path cannot resolve

A qualified call site was named by its tail (`count` for `\count($x)`),
so when `resolveQualifiedFreeCall` refused the path, the free-call pass
fell through to its simple-name tiers and could bind a same-named
function in the caller's namespace: `\count($x)` -> a user `Qff\Ext\count`,
`\Vendor\Pkg\make()` -> `Qff\Ext\make`, and a twice-declared
`\Qff\Dup\make()` the same way. Before this branch such calls minted no
site at all, so these were new wrong edges.

The site is now named by its whole written path, the same text it carries
as `@reference.qualified-name`. The provider hook still resolves it by
PHP's name rules. Every simple-name tier (scope chain, implicit `this`,
global unique-name fallback) looks up a name with a `\` in it and finds
nothing, so a refused path (builtin, uninstalled package, ambiguous name,
multi-namespace file) leaves the call unresolved. This change is PHP-only.

Lang-resolution A/B against the parent: no edge or drop removed. Added
edges: the three new fixture functions' `new QffWidget()` calls. No edge
from builtinCall, vendorCall or ambiguousCall. Versus the pre-branch base:
29 edges added (25 in the fixture, plus the 4 `use function` edges from
4781ace51), 0 removed, 2 drops added. Receiver counts unchanged. The PHP
golden changes only the new fixture's files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): rebaseline php captures for fail-closed qualified calls

scope-capture (php): a qualified function call site is now named by its
whole path, and the php-qualified-function-factory fixture gains four
files (src/Ext/Calls.php, src/Ext/Shadows.php, lib/dup-a.php,
lib/dup-b.php). Only that fixture moves. Parking it reproduces the
pre-branch fingerprint byte-for-byte. fixture_count 152 -> 156,
capture_groups_fp 2338 -> 2381. receiver-resolution is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(php): preserve qualified identifier bytes

Keep PHP labels and ASCII-only name matching through qualified factories.
Decline names shortened by native non-ASCII whitespace parsing.
Correct fixture comments and give parameterized cases unique audit IDs.

* fix(php): preserve reviewed merge resolutions

* test(php): align golden with fixture comment

The corrected factory annotation comment is included in captured function
text; keep its fine golden digest aligned with the reviewed fixture.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:55:04 +00:00

188 lines
7.5 KiB
TypeScript

/**
* PHP namespaced-function callees (#3498 follow-up).
*
* `rp\make()`, `\Qff\Mod\make()`, `Mod\make()` and `namespace\make()` name a
* function through a path PHP reads against the calling file: its namespace and
* its class/namespace `use` table. These tests pin the capture (the whole path
* survives on the call site and the result binding) and the expansion to a fully
* qualified name (PHP manual, "Name resolution rules").
*/
import { describe, expect, it, vi } from 'vitest';
import type { ParsedImport, ScopeId } from 'gitnexus-shared';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
import { phpProvider } from '../../../../src/core/ingestion/languages/php.js';
import {
phpFunctionFqn,
recordPhpFunctionIndex,
resolvePhpQualifiedCallee,
} from '../../../../src/core/ingestion/languages/php/qualified-function.js';
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
import * as safeParse from '../../../../src/core/tree-sitter/safe-parse.js';
const use = (
localName: string,
targetRaw: string,
importedSymbolKind: 'type' | 'function' = 'type',
): ParsedImport => ({
kind: 'alias',
localName,
importedName: targetRaw.split('\\').at(-1) ?? targetRaw,
alias: localName,
targetRaw,
importedSymbolKind,
});
describe('PHP function ambiguity after a secondary parse timeout', () => {
it.each([
'<?php /* first */ /* éé second */ namespace Real; function make() {}',
'<?php /* header\n */ namespace Real;\nfunction make() {}',
'<?php declare(strict_types=1); /* header */\nnamespace\nReal;\nfunction make() {}',
])('reads the parsed namespace when the scanner reported global: %j', (source) => {
const contents = new Map([
['library.php', source],
['caller.php', '<?php function caller() {}'],
]);
const parsed = [...contents].map(
([file, source]) => extractParsedFile(phpProvider, source, file)!,
);
const scopes = new Map(
parsed.flatMap((file) => file.scopes.map((scope) => [scope.id, scope] as const)),
);
const indexes = {
scopeTree: { getScope: (id: ScopeId) => scopes.get(id) },
} as unknown as ScopeResolutionIndexes;
recordPhpFunctionIndex(
parsed,
indexes,
new Map([
['library.php', ''],
['caller.php', ''],
]),
{ fileContents: contents },
);
const inScope = parsed[1]!.moduleScope;
expect(resolvePhpQualifiedCallee('\\make', inScope, indexes)).toBeUndefined();
expect(resolvePhpQualifiedCallee('\\Real\\make', inScope, indexes)?.filePath).toBe(
'library.php',
);
});
it('retains possible duplicates instead of selecting the remaining function', () => {
const contents = new Map([
['known.php', '<?php namespace Ns; function make() {}'],
['multi.php', '<?php namespace Ns { function make() {} } namespace Other {}'],
['caller.php', '<?php function caller() {}'],
]);
const parsed = [...contents].map(([file, source]) => {
const value = extractParsedFile(phpProvider, source, file);
if (value === undefined) throw new Error(`parse failed: ${file}`);
return value;
});
const scopes = new Map(
parsed.flatMap((file) => file.scopes.map((scope) => [scope.id, scope] as const)),
);
const indexes = {
scopeTree: { getScope: (id: ScopeId) => scopes.get(id) },
} as unknown as ScopeResolutionIndexes;
const parse = vi.spyOn(safeParse, 'parseSourceSafe').mockImplementationOnce(() => {
throw new safeParse.ParseTimeoutError(1, 'multi.php');
});
try {
recordPhpFunctionIndex(
parsed,
indexes,
new Map([
['known.php', 'Ns'],
['multi.php', 'Ns'],
['caller.php', ''],
]),
{ fileContents: contents },
);
const caller = parsed.find((file) => file.filePath === 'caller.php');
if (caller === undefined) throw new Error('caller missing');
expect(resolvePhpQualifiedCallee('\\Ns\\make', caller.moduleScope, indexes)).toBeUndefined();
} finally {
parse.mockRestore();
}
});
});
describe('phpFunctionFqn', () => {
it.each([
['\\Qff\\Mod\\make', 'Qff\\App', [use('Qff', 'Other')], 'Qff\\Mod\\make'],
['\\make', 'Qff\\App', [], 'make'],
['namespace\\make', 'Qff\\Mod', [], 'Qff\\Mod\\make'],
['NAMESPACE\\Sub\\make', 'Qff', [], 'Qff\\Sub\\make'],
['namespace\\make', '', [], 'make'],
['rp\\make', 'Qff\\App', [use('rp', 'Qff\\Mod')], 'Qff\\Mod\\make'],
['RP\\make', 'Qff\\App', [use('rp', 'Qff\\Mod')], 'Qff\\Mod\\make'],
['rp\\Sub\\make', 'Qff\\App', [use('rp', '\\Qff\\Mod')], 'Qff\\Mod\\Sub\\make'],
['Mod\\make', 'Qff', [], 'Qff\\Mod\\make'],
['Mod\\make', '', [], 'Mod\\make'],
['rp\\make', 'Qff\\App', [use('rp', 'Qff\\Mod\\rp', 'function')], 'Qff\\App\\rp\\make'],
['café\\make', '', [], 'café\\make'],
['\\UnicodeFactory\\fabriqué', '', [], 'UnicodeFactory\\fabriqué'],
['\\£\\¥', '', [], '£\\¥'],
['e\u0301\\cre\u0301er', '', [], 'e\u0301\\cre\u0301er'],
['café\\make', 'Caller', [use('café', 'Actual')], 'Actual\\make'],
['CAFé\\make', 'Caller', [use('café', 'Actual')], 'Actual\\make'],
['cafÉ\\make', 'Caller', [use('café', 'Actual')], 'Caller\\cafÉ\\make'],
['name\u017Fpace\\make', 'Caller', [], 'Caller\\name\u017Fpace\\make'],
['\\Home\\make\u00A0', 'Caller', [], 'Home\\make\u00A0'],
[' \t\\Home\\make\n', 'Caller', [], 'Home\\make'],
])(
'%#: expands %s in namespace "%s" to its fully qualified name',
(callee, namespace, imports, fqn) => {
expect(phpFunctionFqn(callee, { namespace, imports })).toBe(fqn);
},
);
it.each([
['make', [use('make', 'Qff\\Mod\\make', 'function')]],
['rp\\', []],
['\\', []],
['$fn', []],
['rp\\$fn', []],
['123\\make', []],
['Ns\\123', []],
])('refuses %s, which names no qualified function', (callee, imports) => {
expect(phpFunctionFqn(callee, { namespace: 'Qff\\App', imports })).toBeUndefined();
});
});
describe('PHP qualified function-call captures', () => {
it.each([
['rp\\make()', 'rp\\make'],
['\\Qff\\Mod\\make()', '\\Qff\\Mod\\make'],
['namespace\\make()', 'namespace\\make'],
['café\\make()', 'café\\make'],
['\\UnicodeFactory\\fabriqué()', '\\UnicodeFactory\\fabriqué'],
['\\£\\¥()', '\\£\\¥'],
['e\u0301\\cre\u0301er()', 'e\u0301\\cre\u0301er'],
])('keeps the callee path of %s and marks the binding as a call result', (call, path) => {
const parsed = extractParsedFile(
phpProvider,
`<?php\nnamespace Qff\\App;\nfunction caller() { $x = ${call}; }\n`,
'caller.php',
);
const calls = (parsed?.referenceSites ?? [])
.filter((site) => site.kind === 'call')
.map((site) => [site.name, site.rawQualifiedName, site.callForm]);
expect(calls).toEqual([[path, path, 'free']]);
const bound = (parsed?.scopes ?? [])
.flatMap((scope) => [...scope.typeBindings])
.filter(([name]) => name === '$x')
.map(([, ref]) => ref.rawName);
expect(bound).toEqual([call]);
});
it('refuses a qualified callee shortened by non-ASCII grammar whitespace', () => {
const parsed = extractParsedFile(
phpProvider,
'<?php function caller() { $x = \\Home\\make\u00A0(); }',
'caller.php',
);
expect(parsed?.referenceSites.filter((site) => site.kind === 'call')).toEqual([]);
expect(
parsed?.scopes.flatMap((scope) => [...scope.typeBindings]).filter(([name]) => name === '$x'),
).toEqual([]);
});
});