fix(scope-resolution): give PHP's $this the same self-receiver exemption (#2699)

Review finding (LOW). The Step-1 skip added in this PR exempts `this`/`self`,
but the receiver name arrives as the reference node's RAW SOURCE TEXT —
`extractExplicitReceiver` returns `cap.text` verbatim — so PHP's `$this->x`
presents as the string "$this" and matched neither entry. PHP was the one
supported language whose self-receiver got no exemption at all.

Measured, and the measurement is why this is framed as consistency rather
than a bug fix:

  - Corpus delta ZERO. 762-file TypeScript corpus, CALLS+ACCESSES set diff:
    13179 -> 13179, added 0, removed 0. So no INCREMENTAL_SCHEMA_VERSION bump
    (stays 20), per the plan's decision rule.
  - No PHP shape found that DISCRIMINATES. Both the simple `$this->prop` /
    `$this->helper()` shapes and a closure reading `$this->…` inside a method
    that also declares a same-named local produce byte-identical edge sets
    with `$this` present and absent — Step 2 resolves the receiver's type
    first. The added test is therefore labelled a COMPANION INVARIANT, exactly
    as the `this.baseUrl` case beside it is, and does not claim to prove the
    fix.

It is still worth making: the exemption is protective, and the 709-removed /
0-true-lost measurement that justified the narrow guard was TypeScript-only,
so PHP's safety was never established by evidence. This closes that by
construction.

Two corrections to what the plan assumed, both found by checking:

  - The plan (and my first draft of this comment) claimed the codebase had no
    precedent for handling a sigil'd receiver name. FALSE: `THIS_RECEIVERS` in
    `core/ingestion/type-env.ts:244` has always listed `$this`, and it is the
    ingestion-side twin of this very list. The precedent does not merely
    exist, it validates the approach chosen here — list the spelling as data,
    do not strip sigils.
  - That twin also lists `Me`. Deliberately NOT mirrored: no entry in
    `SupportedLanguages` is Visual Basic, so it could only ever exempt a
    variable that happens to be called `Me`.

The two lists are otherwise the same set with nothing enforcing it — a fifth
instance of the twin-list drift class this PR keeps meeting. A drift guard is
the right fix and is out of scope here; noted for follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
This commit is contained in:
Gergo Magyar 2026-07-27 10:45:52 +00:00
parent de2c0c6966
commit 13d5e738c1
2 changed files with 83 additions and 1 deletions

View file

@ -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,

View file

@ -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'),
[
'<?php',
'class Box {',
" private $baseUrl = 'https://example.com';",
' public function helper() {',
' return 1;',
' }',
' public function read() {',
" $baseUrl = 'shadow';",
' $fn = function () {',
' return $this->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 });
}
});
});