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, + ); + }); +});