Files
codegraph/__tests__/ts-this-field-call.test.ts
Colby Mchenry f40db4b9cc fix(js,ts): index the functions in every named object literal, not only exported ones (#2300) (#2363)
Issue #2300: a function written inside an object literal became a symbol
only when the object was an `export const`. A plain `const api = {...}`,
an object declared inside an IIFE or a function, and a namespace hung on
the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes,
and no node for `WS` either. Calls made inside those members were credited
to the enclosing constant or lost, and calls into them resolved to
nothing. Script-tag JavaScript, written almost entirely this way, was
mostly missing from callers and impact.

Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted
members only for exported object-of-functions; any other literal was
walked as one opaque initializer, and an assignment to a member path was
never a declaration. Resolution had no way to reach a member through
`App.init()`, `window.App.init()` or `App.utils.pad()`.

Fix (extraction, TS and kernel byte-identical): a named object literal
owns its function members (method shorthand, `key: function`, `key: () =>`,
generators; static keys only), whether it is declared at module scope,
in a function body or IIFE, assigned to a path (`window.App = {...}`,
`App.utils = {...}`) or assigned to a plain name at module level
(`dw_page = {...}`). Members are `function` nodes qualified under the owner
(`api::load`, `window.App::init`, `App.utils::pad`), the exported case
included; a path owner is named by its last link and qualified by the
path. Calls in a member are the member's; other values are walked where
they were before. CommonJS exports, prototypes, `this.x = {...}`,
call-argument literals, a name reassigned inside a function, and
generated or minified files keep the old shape. Minified files are now
also recognised by content, in both extractors, and the kernel's
`.min.js` pattern matches the TS one.

Fix (resolution): a member is reached through its object only:
`App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's
`this.render()`, a `const { init } = App` binding; never a bare `init()`.
A same-file holder is chosen by lexical block (an IIFE's own `App` first);
otherwise a global one (`window.App = {...}`, or a classic script's
top-level `App`) when the caller neither imports nor binds the name.
Arrow members keep the `this` of the method around the literal, in the
`this.x` resolvers too. A dotted-path holder is never reached by its last
name alone, and Svelte's `$store` rule now applies only in `.svelte`
components, so the new local holders are not taken for `$n` in plain
scripts. The lookups read each file once (destructuring patterns only
when the raw text has a `} =`) and keep only the last 32 files' scans.

Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified
members exported or not, path owners, IIFE/local owners, static keys,
no bare-name reach, `<script setup>` contains edges, SFC languages, test
scenarios), re-implemented on current main. Not taken: the
EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle
with its ref/edge metadata and kernel ref patching (replaced by
resolution-time lookups), naming the owner `window.WS`, rewriting
`window.X.m()` ref names, flipping exported members' isExported, and
unrelated bare-call changes.

Verification: new js-object-literal-members suite (native + wasm, 18
tests); the issue's shapes fail on main and the guard test fails without
the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1
(object-literal owners LF/CRLF, minified bundles); kernel parity sweeps
show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo.

Validation (kernel loaded, before = origin/main 023fc311):
- DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the
  member, 4 re-resolved from a wrong LinkWizard::init to the right init.
- TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33
  re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19
  wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a
  helper local to jQuery Mobile's scrollstart setup).
- excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3
  guess dropped.
- vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the
  ApiService constant to its members.
New self-loops are real recursion, bar one lazily redefined method that
calls itself as written. 81 sampled added edges, 80 correct
(the other is main's own window.open guess, re-attributed). Whole-run
index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s,
DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s
(within run-to-run noise).

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:45:43 +00:00

173 lines
7.2 KiB
TypeScript

/**
* A TS/JS call through a field of the enclosing class resolves on the field's
* declared type, never by bare name (#1496).
*
* `this.mailer.send(msg)` inside `Notifier.send()` used to be emitted as the
* bare `send`, which exact-matched the nearest same-named method — the
* calling method itself. The stored self-edge `Notifier::send → Notifier::send`
* made callers, callees, impact and trace silently wrong on exactly the
* shape a delegating wrapper takes. The identical call resolved correctly
* whenever the wrapper had any other name.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
let dir: string;
let cg: CodeGraph;
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1496-'));
fs.mkdirSync(path.join(dir, 'src'));
const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, 'src', rel), body);
w('mailer.ts', 'export class Mailer {\n send(msg: string): string { return msg; }\n}\n');
w(
'notifier.ts',
"import { Mailer } from './mailer';\n" +
'export class Notifier {\n' +
' constructor(private readonly mailer: Mailer, private items: string[]) {}\n' +
' send(msg: string): string { return this.mailer.send(msg); }\n' +
' other(msg: string): string { return this.mailer.send(msg); }\n' +
' push(msg: string): void { this.items.push(msg); }\n' +
'}\n'
);
// Plain JS: the field's type is only known from its `new` initializer.
// Its sibling-extension cases are covered by release-main-regressions.test.ts.
w('legacy-mailer.js', 'class LegacyMailer {\n send(msg) { return msg; }\n}\nmodule.exports = { LegacyMailer };\n');
w(
'legacy.js',
"const { LegacyMailer } = require('./legacy-mailer');\n" +
'class LegacyNotifier {\n' +
' constructor() { this.mailer = new LegacyMailer(); }\n' +
' send(msg) { return this.mailer.send(msg); }\n' +
'}\n' +
'module.exports = { LegacyNotifier };\n'
);
// A field typed as the type OF a value: an object literal used as a namespace.
w(
'storage.ts',
'export const DraftHubStorage = {\n' +
' async get(key: string): Promise<string> { return key; },\n' +
' async getSettings(): Promise<object> { return {}; },\n' +
'};\n'
);
w(
'keeper.ts',
"import { DraftHubStorage } from './storage';\n" +
'export class Keeper {\n' +
' constructor(private readonly storage: typeof DraftHubStorage) {}\n' +
' async get(key: string): Promise<string> { return this.storage.get(key); }\n' +
' async settings(): Promise<object> { return this.storage.getSettings(); }\n' +
'}\n'
);
// ES private fields (#1987). `Outbox::send` and `Cart::add` sit in the same
// file, so a bare-name guess would pick them over the field's real type.
w(
'vault.ts',
"import { Mailer } from './mailer';\n" +
'export class Outbox {\n send(msg: string): string { return msg; }\n}\n' +
'export class Cart {\n add(item: string): void {}\n}\n' +
'export class Vault {\n' +
' #mailer: Mailer;\n' +
' #backup = new Mailer();\n' +
' #items = new Set<string>();\n' +
' constructor(m: Mailer) { this.#mailer = m; }\n' +
' notify(msg: string): string { return this.#mailer.send(msg); }\n' +
' fallback(msg: string): string { return this.#backup.send(msg); }\n' +
' put(x: string): void { this.#items.add(x); }\n' +
'}\n'
);
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});
afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});
const method = (qn: string) => cg.getNodesByKind('method').find((n) => n.qualifiedName === qn)!;
const calleesOf = (qn: string) => cg.getCallees(method(qn).id).map(({ node }) => node.qualifiedName).sort();
describe('this.<field>.<method>() (#1496)', () => {
it('resolves on the field\'s declared type even when the wrapper shares the method name', () => {
expect(calleesOf('Notifier::send')).toEqual(['Mailer::send']);
expect(calleesOf('Notifier::other')).toEqual(['Mailer::send']);
// No self-edge anywhere.
const self = cg.getCallers(method('Notifier::send').id).some(({ node }) => node.id === method('Notifier::send').id);
expect(self).toBe(false);
});
it('reads a JS field initialized in the constructor', () => {
expect(calleesOf('LegacyNotifier::send')).toEqual(['LegacyMailer::send']);
});
it('leaves a builtin-typed field unresolved rather than guessing a same-named method', () => {
// `this.items.push()` — `string[]` names no project type; the wrapper `push`
// must not become its own callee.
expect(calleesOf('Notifier::push')).toEqual([]);
});
it('resolves a field typed `typeof <objectLiteral>` onto the literal\'s member', () => {
// The members are functions inside the constant's extent (#1573), qualified
// under it (#2300).
expect(calleesOf('Keeper::settings')).toEqual(['DraftHubStorage::getSettings']);
expect(calleesOf('Keeper::get')).toEqual(['DraftHubStorage::get']);
const self = cg.getCallers(method('Keeper::get').id).some(({ node }) => node.id === method('Keeper::get').id);
expect(self).toBe(false);
});
it('resolves an ES private field on its declared or constructed type (#1987)', () => {
expect(calleesOf('Vault::notify')).toEqual(['Mailer::send']);
expect(calleesOf('Vault::fallback')).toEqual(['Mailer::send']);
});
it('leaves a builtin-typed ES private field unresolved (#1987)', () => {
// `this.#items.add()` on a Set must not bind to the project's `Cart::add`.
expect(calleesOf('Vault::put')).toEqual([]);
});
});
describe.each(['ts', 'tsx', 'js', 'jsx'])('private field receivers in %s (#1987)', (ext) => {
let temp: string;
let graph: CodeGraph | undefined;
afterEach(() => {
graph?.destroy();
graph = undefined;
if (temp) fs.rmSync(temp, { recursive: true, force: true });
});
it.each(['LF', 'CRLF'])('keeps optional receivers distinct from public fields (%s)', async (ending) => {
temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-private-1987-'));
const source = `
export class Mailer { send() {} }
export class Cart { add() {} }
export class Vault {
#mailer = new Mailer();
#items = new Set();
items = new Cart();
notify() { this.#mailer?.send(); }
optional() { this.#mailer.send?.(); }
put() { this.#items?.add('x'); }
publicPut() { this.items.add('x'); }
}
`;
fs.writeFileSync(path.join(temp, `vault.${ext}`), ending === 'CRLF' ? source.replace(/\n/g, '\r\n') : source);
graph = await CodeGraph.init(temp, { index: true });
const callees = (name: string) => {
const caller = graph!.getNodesByKind('method').find(n => n.qualifiedName === `Vault::${name}`)!;
expect(caller).toBeDefined();
return graph!.getCallees(caller.id).map(c => c.node.qualifiedName);
};
expect(callees('notify')).toEqual(['Mailer::send']);
expect(callees('optional')).toEqual(['Mailer::send']);
expect(callees('put')).toEqual([]);
expect(callees('publicPut')).toEqual(['Cart::add']);
});
});