From a8201cd9c976264c89093412776af79982c64287 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 11 May 2026 09:23:50 +0100 Subject: [PATCH] feat(scope-resolution): add emitUnresolvedReceiverEdges hook for dynamic languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ingestion/languages/php/scope-resolver.ts | 4 +++ .../contract/scope-resolver.ts | 28 +++++++++++++++++++ .../scope-resolution/pipeline/run.ts | 13 ++++++++- .../test/integration/resolvers/php.test.ts | 19 +++---------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts index ee1b47327..b0c2a666e 100644 --- a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -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 }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 1571ee682..de3bfbddf 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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, + model: SemanticModel, + ) => number; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index e2c734a43..933fa43d9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, }; } diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index c8cd74385..d930c9599 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -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', );