/** * #2701 — `this` inside an ordinary JS/TS `function` is NOT the enclosing * instance, so `this.m()` there must not emit a `CALLS` edge to the enclosing * class's member. Only an arrow inherits `this`. * * ECMA-262 gives an arrow `[[ThisMode]] = lexical`: it has no `this` binding in * its environment record, so the lookup passes through to the enclosing * environment. Every other function form binds `this` at call time. `tsc` draws * the same line by resolving `this` through `getThisContainer` with * `includeArrowFunctions = false`. * * The fix spans three layers. An earlier version of this comment claimed all * three were independently load-bearing because "the false edge survived * removing any one of them alone". That was measured DURING development and is * FALSE for the shipped code — it was carried into the final commit without * being re-tested. Corrected: * * 1. `Scope.ownsReceivers`, set from the `@receiver-owner.this` query marker, * stops both receiver-type walks (`findReceiverTypeBinding` in ingestion, * `lookupReceiverType` in gitnexus-shared's `lookup-core`). * 2. `LanguageTypeConfig.thisBoundaryNodeTypes` stops the type-env AST walk * that infers a receiver's type during capture. * 3. `isReceiverOwnedButUnbound` makes `receiver-bound-calls` SUPPRESS the * site. This is the one that decides the outcome for the fixtures below: * without it the member still resolved by NAME through `lookupCore`'s * lexical chain — the class-body scope binds `m`, two scopes up. * * Layer 3 runs FIRST (`emitReceiverBoundCalls` marks the site in `handledSites`, * which `emitReferencesViaLookup` then skips), so it SUBSUMES layer 1 for an * explicit `this` receiver. Removing layer 1's gate in `lookup-core.ts` leaves * every test in this file passing — verified by experiment. * * That gate is nonetheless RETAINED, and deleting it would be a mistake: the * `receiver-bound-calls` suppression only covers EXPLICIT receivers * (`if (site.explicitReceiver === undefined) continue;`), while `lookup-core`'s * gate is also reached for IMPLICIT ones via `IMPLICIT_RECEIVERS` in * `resolveReceiverOwner` — a bare `m()` inside a nested `function` inside a * method. The experiment above establishes that gate is UNTESTED, not that it is * unreachable. It needs a test for the implicit-receiver path; until then, do * not treat its removability as demonstrated. * * WHAT THIS DELIBERATELY GIVES UP. `.bind(this)`, `.call(this)` and * `forEach(fn, thisArg)` DO make `this` the instance at runtime; their edges * were correct and are now dropped. Their correctness is fixed at the call * site, which a scope-level rule cannot see, so the choice is between losing * them and keeping every detached-callback false positive. The pinned cases at * the bottom record that trade so a future change to it is deliberate. */ import { describe, expect, it, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js'; vi.setConfig({ testTimeout: 90_000 }); const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip; /** `CALLS` edges in a one-file repo as sorted `source -> target` strings. */ const callEdgesFor = async (filename: string, source: string): Promise => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-this-boundary-')); try { fs.writeFileSync(path.join(dir, filename), source, 'utf-8'); const result = await runPipelineFromRepo(dir, () => {}, { workerPoolSize: 1, workerUrlForTest: DIST_WORKER_URL, }); return result.graph.relationships .filter((rel) => rel.type === 'CALLS') .map((rel) => `${rel.sourceId} -> ${rel.targetId}`) .sort(); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }; describeIfWorkerBuilt('an arrow inherits `this`; every other function form binds it', () => { it('TypeScript: a nested `function` emits no edge, its arrow twin does', async () => { // The two methods differ ONLY in arrow vs `function`, so any edge // difference between them is the boundary and nothing else. expect( await callEdgesFor( 'c.ts', [ 'export class C {', ' m(): void {}', ' viaArrow(): void { const good = () => { this.m(); }; good(); }', ' viaFn(): void { const bad = function () { this.m(); }; bad(); }', '}', ].join('\n'), ), // The closure ids carry their enclosing METHOD (`C.viaArrow.good`), not // just the class — #2699. Note both phases agree on that name: the caller // edge and the definition it points at were built independently. ).toEqual([ 'Function:c.ts:C.viaArrow.good@2:21 -> Method:c.ts:C.m#0', 'Method:c.ts:C.viaArrow#0 -> Function:c.ts:C.viaArrow.good@2:21', 'Method:c.ts:C.viaFn#0 -> Function:c.ts:C.viaFn.bad@3:18', ]); }); it('JavaScript: the same boundary, via the JavaScript grammar', async () => { // JS and TS have separate query files; a marker added to one only would // pass the TypeScript case above and silently leave JavaScript broken. expect( await callEdgesFor( 'h.js', [ 'class H {', ' m() {}', ' viaArrow() { const good = () => { this.m(); }; return good; }', ' viaFn() { const bad = function () { this.m(); }; return bad; }', ' direct() { this.m(); }', '}', 'module.exports = { H };', ].join('\n'), ), ).toEqual([ 'Function:h.js:H.viaArrow.good@2:15 -> Method:h.js:H.m#0', 'Method:h.js:H.direct#0 -> Method:h.js:H.m#0', ]); }); it('a callback `function` passed to forEach does not reach the enclosing class', async () => { // The original motivating shape: the bug arrow functions were introduced // to avoid. `run` must have NO outgoing call to `m`. expect( await callEdgesFor( 'f.ts', [ 'export class F {', ' m(): void {}', ' run(xs: number[]): void { xs.forEach(function () { this.m(); }); }', '}', ].join('\n'), ), ).toEqual([]); }); it('a plain `this.m()` in a method still resolves', async () => { // The boundary must not swallow the ordinary case: a `method_definition` // carries the marker too, but its own synthesized `this` binding is // consulted first. expect( await callEdgesFor( 'd.ts', ['export class D {', ' m(): void {}', ' direct(): void { this.m(); }', '}'].join('\n'), ), ).toEqual(['Method:d.ts:D.direct#0 -> Method:d.ts:D.m#0']); }); it('a class-field arrow still resolves', async () => { // `m = () => {}` is lexically bound to the instance, so it keeps its edge. // The caller is the class itself: a field initializer has no method scope. expect( await callEdgesFor( 'g.ts', ['export class G {', ' m(): void {}', ' field = (): void => { this.m(); };', '}'].join( '\n', ), ), ).toEqual(['Class:g.ts:G -> Method:g.ts:G.m#0']); }); it('a generator `function` is a boundary too', async () => { expect( await callEdgesFor( 'gen.ts', [ 'export class Gen {', ' m(): void {}', ' run() { return function* () { this.m(); }; }', '}', ].join('\n'), ), ).toEqual([]); }); it('leaves other languages untouched: a Kotlin lambda still sees the receiver', async () => { // `ownsReceivers` is unset for every language but JS/TS, so a Kotlin lambda // — which DOES capture the enclosing `this` — must keep resolving. This is // the guard against the boundary leaking into shared code. expect( await callEdgesFor( 'K.kt', ['class K {', ' fun m() {}', ' fun run() { val f = { this.m() }; f() }', '}'].join( '\n', ), ), // Attributed to `f` since #2699 S2. Kotlin still scopes `lambda_literal` // as a BLOCK (#1757 — that has NOT changed), but a Block-kind scope is now // accepted as a caller anchor when the scope IS the callable's body, so // the lambda is its own anchor. The property this test exists for is // unchanged and is what the assertion still checks: `this` inside a Kotlin // lambda resolves to the enclosing receiver, so the `this.m()` edge exists. // Only its SOURCE moved, from `run` to `run.f`. ).toContain('Method:K.kt:K.run.f@2:16 -> Method:K.kt:K.m#0'); }); }); describeIfWorkerBuilt('receiver rebinding is not modelled — pinned, not endorsed', () => { // Each of these is CORRECT at runtime and produces no edge. The rebinding // happens at the call site, which a scope-level rule cannot see; modelling it // needs call-site receiver tracking, which is a separate concern. Pinned so // that a future change here is a decision rather than a surprise. it('`.bind(this)` loses its edge', async () => { expect( await callEdgesFor( 'b.ts', [ 'export class B {', ' m(): void {}', ' run() { return function (this: B) { this.m(); }.bind(this); }', '}', ].join('\n'), ), ).toEqual([]); }); it('a forEach `thisArg` loses its edge', async () => { expect( await callEdgesFor( 't.ts', [ 'export class T {', ' m(): void {}', ' run(xs: number[]): void { xs.forEach(function (this: T) { this.m(); }, this); }', '}', ].join('\n'), ), ).toEqual([]); }); it('`this` in a static method no longer reaches the instance member', async () => { // `this` in a static context is the constructor, not an instance, so an // edge to the INSTANCE method `m` was wrong in the other direction. It was // previously emitted by the same lexical-name fallback this change closes. expect( await callEdgesFor( 's.ts', ['export class S {', ' m(): void {}', ' static go(): void { this.m(); }', '}'].join('\n'), ), ).toEqual([]); }); });