From e90f77cb2a6ee67cb976e6b9ff40d1bf0d4276a0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 12 May 2026 12:36:34 +0100 Subject: [PATCH] fix(php): tighten unresolved-receiver fallback to exact-required arity (U4) phpEmitUnresolvedReceiverEdges emits 0.6-confidence CALLS edges for member-call sites whose receiver has no type binding (PHP 'mixed'-typed parameters, untyped variables) when the called method name has a single workspace-wide candidate. The existing first-stage gate accepted any argCount in 'min..max' (or '>= min' with variadic), which over-emits when the lone candidate has optional / defaulted parameters - the fallback fires for calls that happen to fit the wider range but weren't meant for that target. Add an EXACT-required-arity gate for the 0.6-confidence path only: for fixed-arity candidates, require argCount === requiredParameterCount. Variadic candidates ('...' marker in parameterTypes) keep the relaxed '>= required' semantics already enforced by the first-stage check. Adds php-unresolved-receiver-arity fixture with seven scenarios covering happy path, default-param exact match, default-param over-arity (the narrowed case), variadic at/above/below required, and the class- detection sanity check. Pre-fix the over-arity scenario emits a stray 0.6 edge; post-fix it does not. All existing PHP integration tests continue to pass. --- .../ingestion/languages/php/scope-resolver.ts | 23 ++++++++ .../app/Models/Handler.php | 26 +++++++++ .../app/Services/Caller.php | 58 +++++++++++++++++++ .../composer.json | 5 ++ .../test/integration/resolvers/php.test.ts | 58 +++++++++++++++++++ 5 files changed, 170 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/app/Models/Handler.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/app/Services/Caller.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts index fd053506a..8b1fcf41b 100644 --- a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -303,6 +303,29 @@ function phpEmitUnresolvedReceiverEdges( continue; } + // Tighten the fallback further with an EXACT-required-arity gate + // (Finding 8 / U4): the first-stage `narrowOverloadCandidates` + // accepts any argCount in `min..max` (or `>= min` when variadic), + // which over-emits 0.6-confidence edges for common method names + // whose only workspace candidate has optional / defaulted params. + // For the fallback path only, require argCount === required for + // fixed-arity candidates. Variadic candidates keep the relaxed + // `argCount >= required` semantics (already enforced by the first- + // stage check, so no extra work here). + const min = fnDef.requiredParameterCount; + const hasVarArgs = + fnDef.parameterTypes !== undefined && + fnDef.parameterTypes.some((t) => t === '...' || t.startsWith('...')); + if ( + min !== undefined && + Number.isFinite(site.arity) && + site.arity >= 0 && + !hasVarArgs && + site.arity !== min + ) { + continue; + } + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); if (callerGraphId === undefined) continue; const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup); diff --git a/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/app/Models/Handler.php b/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/app/Models/Handler.php new file mode 100644 index 000000000..68583c58a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/app/Models/Handler.php @@ -0,0 +1,26 @@ += required → edge emitted + * - variadic candidate, argCount < required → NO edge + */ +class Caller +{ + public function callHappyPath($h): void + { + // happyPath(): min=0. argCount=0 → exact match. Edge. + $h->happyPath(); + } + + public function callDefaultExactRequired($h): void + { + // withDefault($a, $b=0): min=1. argCount=1 === min → exact match. Edge. + $h->withDefault('a'); + } + + public function callDefaultBeyondRequired($h): void + { + // withDefault($a, $b=0): min=1, max=2. argCount=2 > min. + // Pre-fix: first-stage narrow accepts (2 <= 2), edge emitted. + // Post-fix: exact-required gate rejects (2 !== 1), no edge. + $h->withDefault('a', 99); + } + + public function callVariadicAtRequired($h): void + { + // variadicLog($level, ...$args): min=1, hasVarArgs. + // argCount=1 === min → edge emitted (variadic relaxed path). + $h->variadicLog('info'); + } + + public function callVariadicBeyondRequired($h): void + { + // variadicLog($level, ...$args): min=1, hasVarArgs. + // argCount=2 > min, variadic → edge emitted. + $h->variadicLog('info', 'arg1'); + } + + public function callVariadicBelowRequired($h): void + { + // variadicLogTwoRequired($a, $b, ...$rest): min=2, hasVarArgs. + // argCount=1 < min → no edge (first-stage rejects). Both pre/post-fix. + $h->variadicLogTwoRequired('only-one'); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json b/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json new file mode 100644 index 000000000..60ede80e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json @@ -0,0 +1,5 @@ +{ + "autoload": { + "psr-4": { "App\\": "app/" } + } +} diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 5c50727a9..336e5dee2 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -2237,3 +2237,61 @@ describe('PHP dynamic dispatch — negative regression suite', () => { expect(reads.length).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// phpEmitUnresolvedReceiverEdges exact-required-arity gate (Finding 8 / U4). +// The 0.6-confidence fallback for untyped receivers now requires argCount +// to exactly match the candidate's required parameter count for fixed- +// arity candidates. Variadic candidates keep the relaxed argCount >= +// required semantics. +// --------------------------------------------------------------------------- + +describe('PHP unresolved-receiver fallback exact-required-arity gate', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-unresolved-receiver-arity'), + () => {}, + ); + }, 60000); + + const fallbackEdgeFromTo = (source: string, target: string) => + getRelationships(result, 'CALLS').filter( + (c) => + c.source === source && c.target === target && c.targetFilePath === 'app/Models/Handler.php', + ); + + it('detects Handler and Caller classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Handler'); + expect(getNodesByLabel(result, 'Class')).toContain('Caller'); + }); + + it('happy path: argCount === required (0===0) emits 0.6 fallback edge', () => { + expect(fallbackEdgeFromTo('callHappyPath', 'happyPath').length).toBe(1); + }); + + it('argCount === required (1===1) on candidate with default param still emits edge', () => { + expect(fallbackEdgeFromTo('callDefaultExactRequired', 'withDefault').length).toBe(1); + }); + + it('argCount > required (2>1) on candidate with default param emits NO edge post-fix', () => { + // Pre-fix: first-stage narrowOverloadCandidates accepted (1 <= 2 <= 2). + // Post-fix: exact-required gate rejects (2 !== 1). + expect(fallbackEdgeFromTo('callDefaultBeyondRequired', 'withDefault').length).toBe(0); + }); + + it('variadic candidate, argCount === required (1===1) emits edge', () => { + expect(fallbackEdgeFromTo('callVariadicAtRequired', 'variadicLog').length).toBe(1); + }); + + it('variadic candidate, argCount > required (2>1) emits edge (relaxed)', () => { + expect(fallbackEdgeFromTo('callVariadicBeyondRequired', 'variadicLog').length).toBe(1); + }); + + it('variadic candidate, argCount < required (1<2) emits NO edge', () => { + expect(fallbackEdgeFromTo('callVariadicBelowRequired', 'variadicLogTwoRequired').length).toBe( + 0, + ); + }); +});