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', );