feat(scope-resolution): add emitUnresolvedReceiverEdges hook for dynamic languages

Adds an optional post-resolution pass on the ScopeResolver contract:
when a member-call receiver cannot be typed by the scope chain (no
TypeRef), the language may emit CALLS edges via a workspace-wide
unique-name lookup. Runs after emitReceiverBoundCalls and before
emitFreeCallFallback, gated per-language.

PHP wires the hook to recover member calls on mixed/untyped parameters
(e.g. save(mixed $entity) calling $entity->getId()), restoring parity
with the legacy DAG. Re-enables the previously skipped save → getId
test; PHP suite is now 160/160 with no skips in both flag states.
This commit is contained in:
Gergo Magyar 2026-05-11 09:23:50 +01:00
parent 4594f5d36a
commit a8201cd9c9
4 changed files with 48 additions and 16 deletions

View file

@ -295,6 +295,10 @@ const phpScopeResolver: ScopeResolver = {
// PHP hoists method return-type bindings to the Module scope so
// `propagateImportedReturnTypes` can pick them up across files.
hoistTypeBindingsToModule: true,
// PHP recovers member calls on `mixed`/untyped receivers via a
// workspace-wide unique-method-name lookup, mirroring the legacy DAG.
emitUnresolvedReceiverEdges: phpEmitUnresolvedReceiverEdges,
};
export { phpScopeResolver };

View file

@ -576,4 +576,32 @@ export interface ScopeResolver {
readonly treeCache?: { get(filePath: string): unknown };
},
) => void;
/**
* Optional post-resolution pass: emit CALLS edges for member-call sites
* whose receiver cannot be typed by the scope chain (no `TypeRef`).
* Dynamically-typed languages with untyped/`mixed`/`Any` parameters use
* this hook to recover the call edge via workspace-wide method-name
* lookup, mirroring what their legacy resolvers did.
*
* Runs AFTER `emitReceiverBoundCalls` and BEFORE `emitFreeCallFallback`.
* Implementations MUST:
* - Skip sites already in `handledSites` (Invariant I2).
* - Add resolved site keys to `handledSites` before returning.
* - Stay narrow: a unique workspace-wide match is the safe baseline.
* Multi-candidate fallbacks should narrow by arity / argument types
* before emitting to keep false-positive rate bounded.
*
* Returns the number of edges emitted (for telemetry).
*
* Default: undefined (no unresolved-receiver fallback).
*/
readonly emitUnresolvedReceiverEdges?: (
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
handledSites: Set<string>,
model: SemanticModel,
) => number;
}

View file

@ -252,6 +252,17 @@ export function runScopeResolution(
workspaceIndex,
readonlyModel,
);
const unresolvedReceiverExtras =
provider.emitUnresolvedReceiverEdges !== undefined
? provider.emitUnresolvedReceiverEdges(
graph,
indexes,
parsedFiles,
nodeLookup,
handledSites,
readonlyModel,
)
: 0;
const freeCallExtras = emitFreeCallFallback(
graph,
indexes,
@ -299,7 +310,7 @@ export function runScopeResolution(
filesSkipped,
importsEmitted,
resolve: resolveStats,
referenceEdgesEmitted: emitted + receiverExtras + freeCallExtras,
referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras,
referenceSkipped: skipped,
};
}

View file

@ -11,18 +11,9 @@ import {
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
isLegacyResolverParityRun,
type PipelineResult,
} from './helpers.js';
/**
* Whether the current run uses the scope-based (registry-primary) PHP resolver.
* When true, some tests that rely on unresolvable-receiver fallback (mixed-typed
* parameters) are skipped — the contract needs a postResolutionFallback hook to
* support this pattern (tracked as follow-up to RFC #909 Ring 3).
*/
const isRegistryPrimaryRun = !isLegacyResolverParityRun('php');
// ---------------------------------------------------------------------------
// Heritage: PSR-4 imports, extends, implements, trait use, enums, calls
// ---------------------------------------------------------------------------
@ -101,12 +92,10 @@ describe('PHP heritage & import resolution', () => {
});
// save($entity: mixed) calls $entity->getId() — the receiver is typed `mixed`
// so there is no TypeRef in scope. The scope-resolver contract does not yet
// have a postResolutionFallback hook for unresolvable-receiver member calls
// (follow-up to RFC #909 Ring 3). The legacy DAG resolves this via its own
// workspace-wide method-name lookup. Skip in registry-primary mode until the
// contract grows the necessary hook.
it.skipIf(isRegistryPrimaryRun)('emits CALLS edge: save → getId', () => {
// so there is no TypeRef in scope. The scope-resolver `emitUnresolvedReceiverEdges`
// hook (PHP-wired) recovers this case via workspace-wide unique-name lookup,
// matching the legacy DAG behavior.
it('emits CALLS edge: save → getId', () => {
const calls = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'save' && e.target === 'getId',
);