test(php): lock dynamic-dispatch suppression in regression coverage (U3)

The PR #1497 adversarial review confirmed via grammar inspection that
dynamic PHP call shapes ($obj->$method(), $obj->{$method}(),
Class::$method(), $className::method(), call_user_func with variable
/ string / array callables, dynamic property reads) currently produce
zero captures and zero CALLS edges - because the member_call_expression
and scoped_call_expression query patterns constrain `name:` to
`(name)` rather than `(_)`, deliberately excluding variable_name
nodes.

That safety invariant was not regression-tested. A future query.ts
edit relaxing `name:` to `(_)` would silently emit false-positive
edges. Add a php-dynamic-calls fixture covering all 11 dynamic shapes
from Findings 1-7 plus a sanity-check static call that DOES emit an
edge - without the sanity check every zero-edge assertion would pass
even if the pipeline emitted no edges at all.

Adds inline SAFETY-INVARIANT comments above the three load-bearing
query patterns (member_call_expression, scoped_call_expression, and
the static-write property pattern) referencing this fixture as the
regression net.
This commit is contained in:
Gergo Magyar 2026-05-12 12:33:01 +01:00
parent c82fa8369a
commit 14a57b5226
6 changed files with 277 additions and 0 deletions

View file

@ -211,6 +211,15 @@ const PHP_SCOPE_QUERY = `
function: (name) @reference.name) @reference.call.free
;; ── References — member calls: $obj->method() ────────────────────────────
;;
;; SAFETY-INVARIANT (Finding 1 of PR #1497 adversarial review): the name:
;; field is constrained to (name), NOT (_) — tree-sitter-php emits
;; variable_name nodes for dynamic method names ($obj->$method(),
;; $obj->{$method}()). Keeping the pattern at (name) is what suppresses
;; capture of those dynamic shapes. The resolver is structural-only and
;; cannot infer the bound method name from runtime values; relaxing this
;; pattern to (_) would silently emit zero-confidence false-positive
;; edges. Regression: test/fixtures/lang-resolution/php-dynamic-calls/.
(member_call_expression
object: (_) @reference.receiver
@ -223,6 +232,14 @@ const PHP_SCOPE_QUERY = `
name: (name) @reference.name) @reference.call.member
;; ── References — static calls: X::method() ───────────────────────────────
;;
;; Same SAFETY-INVARIANT as member_call_expression above: name: (name)
;; deliberately excludes variable_name so Class::$method() and
;; $className::$method() shapes do not capture. The receiver field uses
;; (_) because static dispatch on a variable receiver
;; ($className::method()) IS captured — but resolution falls through
;; harmlessly when $className has no class type binding. See
;; php-dynamic-calls/ regression suite.
(scoped_call_expression
scope: (_) @reference.receiver
@ -282,6 +299,13 @@ const PHP_SCOPE_QUERY = `
;; ── References — static property writes: User::$count = $x ──────────────
;; Uses @reference.write.static anchor so captures.ts can strip the leading
;; $ from the variable_name capture (static props are stored without $ in graph).
;;
;; SAFETY-INVARIANT (Finding 2 of PR #1497 adversarial review): no
;; read-access property capture exists in this query — dynamic property
;; reads ($obj->$prop, $obj->{$prop}) produce no captures, which is the
;; desired behavior for a structural-only resolver. Adding a read pattern
;; in the future MUST keep name: (name) (not (_)) to preserve the
;; suppression. Regression: php-dynamic-calls/ fixture dynamicPropertyRead.
(assignment_expression
left: (scoped_property_access_expression

View file

@ -0,0 +1,113 @@
<?php
namespace App\Services;
/**
* Exercises every dynamic PHP call/access shape that the tree-sitter
* grammar should NOT capture as a resolvable reference. The negative
* regression suite asserts zero CALLS edges from `Dynamic::*` to any
* `Targets::*` method whose name only appears in dynamic position.
*
* Findings 1-7 of the adversarial review of PR #1497 confirmed via
* grammar inspection that these patterns produce zero captures; this
* fixture + test pair locks that invariant in regression coverage so
* a future query.ts edit cannot silently break it.
*/
class Dynamic
{
public function memberCallDynamicName(Targets $obj): void
{
// $obj->$method() — dynamic method name via variable_name node.
// Query pattern requires `name: (name)` so this is not captured.
$method = 'dynamicProcess';
$obj->$method();
}
public function memberCallBraceDynamicName(Targets $obj): void
{
// $obj->{$method}() — brace-syntax variant of the above.
$method = 'dynamicBrace';
$obj->{$method}();
}
public function scopedCallDynamicMethodName(): void
{
// ClassName::$method() — dynamic method name on static dispatch.
$method = 'dynamicHandle';
Targets::$method();
}
public function scopedCallVariableClassNameStaticMethod($className): void
{
// $className::method() — class-name is an untyped parameter (no
// type hint, no string-literal assignment that could be picked up
// by a future type-binding heuristic). Receiver IS captured but
// resolution falls through because $className has no class type
// binding in scope. The unresolved-receiver fallback also doesn't
// fire because `dynamicStaticMethod` is unique workspace-wide AND
// exact-arity narrowing in U4 would still match — meaning the
// ONLY thing keeping the edge count at zero today is the absence
// of any type binding for the receiver.
$className::dynamicStaticMethod();
}
public function scopedCallDynamicClassAndMethodName(): void
{
// $className::$method() — both dynamic.
$className = 'App\\Services\\Targets';
$method = 'dynamicScopedDynName';
$className::$method();
}
public function callUserFuncVariableCallable($callable): void
{
// call_user_func($callable, ...) — resolver is structural-only
// and never inspects argument values to infer the callable.
// The literal `call_user_func` itself is an unresolved built-in.
call_user_func($callable);
}
public function callUserFuncArrayVariable($callable, $args): void
{
// call_user_func_array($callable, $args) — unknown-arity variant.
call_user_func_array($callable, $args);
}
public function callUserFuncStringCallable(): void
{
// 'Class::method' string-callable form — argument is a string
// literal, never reaches the function: child of function_call_expression.
call_user_func('App\\Services\\Targets::dynamicCallableMethod');
}
public function callUserFuncArrayObjectCallable(Targets $obj): void
{
// [$obj, 'method'] array-callable form — array is an argument
// value, not the function: child.
call_user_func([$obj, 'dynamicArrayCallableMethod']);
}
public function callUserFuncArrayClassNameCallable(): void
{
// ['Class', 'method'] array-callable with class-name string.
call_user_func(['App\\Services\\Targets', 'dynamicArrayClassCallableMethod']);
}
public function dynamicPropertyRead(Targets $obj): string
{
// $obj->$prop — dynamic property read. No read-access property
// capture pattern exists in query.ts at all (Finding 2).
$prop = 'dynamicProp';
return $obj->$prop;
}
public function sanityStaticCall(Targets $obj): void
{
// The fixture's deliberate sanity-check call. THIS one DOES emit
// a CALLS edge — if the assertion that this edge exists ever
// fails, the test infra is broken, not the dynamic-dispatch
// suppression. Without this, every zero-edge assertion above
// would pass even if the pipeline never emitted any edges at all.
$obj->sanityStaticallyNamedTarget();
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Services;
/**
* Second attractor class. Its purpose is to provide a NON-UNIQUE
* workspace-wide name for `dynamicStaticMethod`, so that the
* phpEmitUnresolvedReceiverEdges 0.6-confidence fallback cannot fire
* for the `$className::dynamicStaticMethod()` site in Dynamic.php.
* That isolates the dynamic-receiver test from the U4 concern
* (Finding 8 — unresolved-receiver fallback tightening).
*/
class OtherTargets
{
public static function dynamicStaticMethod(): void {}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services;
/**
* Attractors: every method name here is unique workspace-wide so that if
* the dynamic-dispatch suppression in the query / resolver layer ever
* regresses, the false-positive CALLS edges would surface against these
* targets.
*/
class Targets
{
public function dynamicProcess(): void {}
public function dynamicHandle(): void {}
public function dynamicBrace(): void {}
public static function dynamicStaticMethod(): void {}
public static function dynamicScopedDynName(): void {}
public function dynamicCallableMethod(): void {}
public function dynamicArrayCallableMethod(): void {}
public function dynamicArrayClassCallableMethod(): void {}
public string $dynamicProp = '';
/**
* Sanity-check target: the fixture's one non-dynamic call DOES reach
* this method, proving the test infra emits CALLS edges normally.
*/
public function sanityStaticallyNamedTarget(): void {}
}

View file

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

View file

@ -2154,3 +2154,86 @@ describe('PHP typed-property double-match dedup', () => {
}
});
});
// ---------------------------------------------------------------------------
// Dynamic PHP constructs MUST NOT capture as resolvable references.
// Findings 1-7 of the PR #1497 adversarial review confirmed via grammar
// inspection that $obj->$method(), call_user_func(...), array/string
// callables, and dynamic property reads produce zero captures. This suite
// locks that invariant in regression so a future query.ts edit cannot
// silently relax `name: (name)` to `name: (_)` and reintroduce false-
// positive edges.
// ---------------------------------------------------------------------------
describe('PHP dynamic dispatch — negative regression suite', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'php-dynamic-calls'), () => {});
}, 60000);
const callsFromDynamicTo = (target: string) =>
getRelationships(result, 'CALLS').filter(
(c) =>
c.target === target &&
// Source is some method on `Dynamic` (the file under test).
c.sourceFilePath === 'app/Services/Dynamic.php',
);
it('detects the Dynamic and Targets classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Dynamic');
expect(getNodesByLabel(result, 'Class')).toContain('Targets');
});
it('sanity check: non-dynamic call DOES emit an edge', () => {
// Without this, every zero-edge assertion below would pass even if the
// pipeline emitted no CALLS edges at all.
expect(callsFromDynamicTo('sanityStaticallyNamedTarget').length).toBe(1);
});
it('$obj->$method() emits no CALLS edge to dynamicProcess', () => {
expect(callsFromDynamicTo('dynamicProcess').length).toBe(0);
});
it('$obj->{$method}() emits no CALLS edge to dynamicBrace', () => {
expect(callsFromDynamicTo('dynamicBrace').length).toBe(0);
});
it('Class::$method() emits no CALLS edge to dynamicHandle', () => {
expect(callsFromDynamicTo('dynamicHandle').length).toBe(0);
});
it('$className::method() with untyped variable receiver emits no CALLS edge', () => {
// Two attractor classes (Targets and OtherTargets) both expose
// dynamicStaticMethod so the unresolved-receiver fallback (Finding 8 /
// U4) cannot fire — that isolates this assertion to the dynamic-
// dispatch suppression at the query / receiver-bound-calls layer.
expect(callsFromDynamicTo('dynamicStaticMethod').length).toBe(0);
});
it('$className::$method() with dynamic class and method names emits no CALLS edge', () => {
expect(callsFromDynamicTo('dynamicScopedDynName').length).toBe(0);
});
it('call_user_func / call_user_func_array string and array callables emit no CALLS edges', () => {
// call_user_func itself is a built-in with no workspace def, so the
// free-call to it is unresolved — no edge to `call_user_func`.
expect(callsFromDynamicTo('call_user_func').length).toBe(0);
expect(callsFromDynamicTo('call_user_func_array').length).toBe(0);
// None of the named targets reachable only via the callable argument
// should pick up a false-positive edge.
expect(callsFromDynamicTo('dynamicCallableMethod').length).toBe(0);
expect(callsFromDynamicTo('dynamicArrayCallableMethod').length).toBe(0);
expect(callsFromDynamicTo('dynamicArrayClassCallableMethod').length).toBe(0);
});
it('dynamic property read ($obj->$prop) emits no read-edge to dynamicProp', () => {
// No read-access property capture pattern exists in query.ts at all
// (Finding 2). Verify no CALLS / READS / write edge targets `dynamicProp`.
expect(callsFromDynamicTo('dynamicProp').length).toBe(0);
const reads = getRelationships(result, 'READS').filter(
(r) => r.target === 'dynamicProp' && r.sourceFilePath === 'app/Services/Dynamic.php',
);
expect(reads.length).toBe(0);
});
});