mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
c82fa8369a
commit
14a57b5226
6 changed files with 277 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
113
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php
vendored
Normal file
113
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
16
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php
vendored
Normal 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 {}
|
||||
}
|
||||
36
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Targets.php
vendored
Normal file
36
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Targets.php
vendored
Normal 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 {}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/composer.json
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/composer.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": { "App\\": "app/" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue