diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index 0297ede78..b98423ca6 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -321,7 +321,31 @@ function resolveReceiverOwner( return undefined; } -const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']); +/** + * Names that denote the enclosing instance rather than an arbitrary object. + * + * Two consumers, and both want the same set: `resolveReceiverOwner` above + * tries them when no explicit receiver is present, and the Step-1 skip in + * `lookupCore` exempts them because for a SELF receiver the members and the + * lexical chain legitimately overlap — a class body is itself a scope that + * binds its members — whereas for a named receiver they never do. + * + * `$this` is matched because the receiver name arrives as the reference node's + * RAW SOURCE TEXT (`extractExplicitReceiver` returns `cap.text` verbatim), so + * PHP's `$this->x` presents as `"$this"`, sigil included. Listing the spelling + * keeps this a data table rather than a language switch — this module resolves + * language behaviour through `providers.*` and `params` only (see the header) + * — and it follows the ingestion-side twin, `THIS_RECEIVERS` in + * `gitnexus/src/core/ingestion/type-env.ts`, which has always listed the + * sigil'd spelling rather than stripping it. Stripping would carry the same + * false-positive surface anyway (a JS variable literally named `$this`). + * + * That twin also lists `Me`, deliberately NOT mirrored here: no entry in + * `SupportedLanguages` uses it, so it can only ever exempt a variable that + * happens to be called `Me`. The two lists are otherwise the same set, and + * nothing enforces that — see the drift guard noted in #2714. + */ +const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this', '$this']); function lookupReceiverType( startScope: ScopeId, diff --git a/gitnexus/test/integration/block-scope-shadowing.test.ts b/gitnexus/test/integration/block-scope-shadowing.test.ts index 4bb147592..fe964430d 100644 --- a/gitnexus/test/integration/block-scope-shadowing.test.ts +++ b/gitnexus/test/integration/block-scope-shadowing.test.ts @@ -170,4 +170,62 @@ describeIfWorkerBuilt('a property read never resolves to a lexical binding of it expect(edges).toHaveLength(1); expect(edges[0]).toContain('-> Property:recv.ts:Box.baseUrl'); }); + + it('PHP: `$this` is exempt from the skip, like `this` and `self`', async () => { + // COMPANION INVARIANT, not a discriminating regression test — and that was + // measured, not assumed. The receiver name arrives as raw source text, so + // PHP's `$this->x` presents as `"$this"` and matched neither exempt name + // until #2714; but no PHP shape tried here depends on Step 1. This fixture + // (a closure reading `$this->…` inside a method that also declares a + // same-named local) produces byte-identical edge sets with `$this` present + // and absent from `IMPLICIT_RECEIVERS`, because Step 2 resolves the + // receiver's type first. + // + // It is kept for the same reason the `this.baseUrl` case above is: the + // exemption is protective. Every other language's self-receiver keeps its + // Step-1 route, and the 762-file corpus that measured "0 true edges lost" + // was TypeScript-only, so PHP's safety was never established by evidence. + // This pins that PHP member resolution through a self-receiver keeps + // working if Step 2's coverage ever changes. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-php-self-')); + try { + fs.writeFileSync( + path.join(dir, 'Box.php'), + [ + 'baseUrl . $this->helper();', + ' };', + ' return $fn() . $baseUrl;', + ' }', + '}', + '', + ].join('\n'), + 'utf-8', + ); + const result = await runPipelineFromRepo(dir, () => {}, { + workerPoolSize: 1, + workerUrlForTest: DIST_WORKER_URL, + keepLocalValueSymbols: true, + }); + const calls = result.graph.relationships + .filter((rel) => rel.type === 'CALLS') + .map((rel) => rel.targetId) + .sort(); + + // `$this->helper()` inside the closure reaches the class method, and the + // same-named local `$baseUrl` never becomes a call target. + expect(calls).toContain('Method:Box.php:Box.helper#0'); + expect(calls.filter((t) => t.includes('baseUrl'))).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); });