diff --git a/gitnexus/src/core/ingestion/languages/php/query.ts b/gitnexus/src/core/ingestion/languages/php/query.ts index 8abdbcbed..fa84c911f 100644 --- a/gitnexus/src/core/ingestion/languages/php/query.ts +++ b/gitnexus/src/core/ingestion/languages/php/query.ts @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php new file mode 100644 index 000000000..4904f3c24 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php @@ -0,0 +1,113 @@ +$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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php new file mode 100644 index 000000000..c8b70a20d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php @@ -0,0 +1,16 @@ + { } }); }); + +// --------------------------------------------------------------------------- +// 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); + }); +});