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.
This commit is contained in:
Gergo Magyar 2026-05-12 12:36:34 +01:00
parent 14a57b5226
commit e90f77cb2a
5 changed files with 170 additions and 0 deletions

View file

@ -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);

View file

@ -0,0 +1,26 @@
<?php
namespace App\Models;
/**
* Single workspace-unique candidate for each fallback-method name so the
* unresolved-receiver fallback in phpEmitUnresolvedReceiverEdges fires
* for untyped receivers. The names are deliberately chosen to NOT collide
* with other fixtures so cross-fixture test interference cannot occur.
*
* Method-arity matrix:
* - happyPath(): min=0, max=0
* - withDefault(string $a, int $b = 0): min=1, max=2
* - variadicLog(string $level, ...$args): min=1, max=undefined, hasVarArgs
* - variadicLogTwoRequired(string $a, string $b, ...$rest): min=2, max=undefined, hasVarArgs
*/
class Handler
{
public function happyPath(): void {}
public function withDefault(string $a, int $b = 0): void {}
public function variadicLog(string $level, ...$args): void {}
public function variadicLogTwoRequired(string $a, string $b, ...$rest): void {}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Services;
/**
* Each method's receiver is an untyped parameter so the high-confidence
* receiver-bound passes drop the site, leaving it for
* phpEmitUnresolvedReceiverEdges (the 0.6-confidence fallback).
*
* The fallback's gate is EXACT-required-arity post-fix:
* - call argCount === fnDef.requiredParameterCount → edge emitted
* - call argCount !== required, non-variadic → NO edge (post-fix; pre-fix may have emitted)
* - variadic candidate, argCount >= 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');
}
}

View file

@ -0,0 +1,5 @@
{
"autoload": {
"psr-4": { "App\\": "app/" }
}
}

View file

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