Files
codegraph/__tests__/js-object-literal-members.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

321 lines
15 KiB
TypeScript

/**
* A named object literal owns its function members (#2300). Members used to
* become nodes only when the object was an `export const`; a plain `const`, an
* object inside an IIFE and one hung on a path (`window.WS = {…}`) produced
* none, so script-tag JavaScript was nearly invisible and the calls inside a
* member were credited to the enclosing constant (or lost).
*
* Now each member is a `function` node qualified under its owner
* (`store::shorthand`, `window.WS::wsM`), the calls written in it are its own,
* and the calls that reach it — `App.init()`, `window.App.init()`, a sibling's
* `this.render()`, `App.utils.pad()`, a classic script's `WS.wsM()` from
* another file — resolve to it. A bare `init()` never does.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getKernel, resetKernelForTests } from '../src/extraction/kernel';
const kernelBuilt = fs.existsSync(path.join(__dirname, '../codegraph-kernel/prebuilds',
`${process.platform}-${process.arch}`, 'codegraph-kernel.node'));
describe.each(['native', 'wasm'].filter((backend) => backend === 'wasm' || kernelBuilt))(
'object-literal members (%s, #2300)', (backend) => {
let dir: string;
let cg: CodeGraph | undefined;
beforeEach(() => {
vi.stubEnv('CODEGRAPH_KERNEL', backend === 'wasm' ? '0' : '1');
vi.stubEnv('CODEGRAPH_KERNEL_LANGS', 'all');
resetKernelForTests();
if (backend === 'native') expect(getKernel()).not.toBeNull();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2300-'));
});
afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(dir, { recursive: true, force: true });
vi.unstubAllEnvs();
resetKernelForTests();
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
cg = await CodeGraph.init(dir, { index: true });
return cg;
}
/** The one node with this qualified name (in `file`, when several share it). */
function node(g: CodeGraph, qualifiedName: string, file?: string) {
const found = g.getNodesByQualifiedName(qualifiedName).filter((n) => !file || n.filePath === file);
expect(found, qualifiedName).toHaveLength(1);
return found[0]!;
}
/** The qualified names at the other end of a node's `calls` edges. */
const calls = (g: CodeGraph, id: string, direction: 'in' | 'out') =>
(direction === 'in' ? g.getIncomingEdges(id) : g.getOutgoingEdges(id))
.filter((e) => e.kind === 'calls')
.map((e) => g.getNode(direction === 'in' ? e.source : e.target)!.qualifiedName)
.sort();
const callers = (g: CodeGraph, qn: string, file?: string) => calls(g, node(g, qn, file).id, 'in');
const callees = (g: CodeGraph, qn: string, file?: string) => calls(g, node(g, qn, file).id, 'out');
it('gives every container shape from the issue its members, and the calls in a member to that member', async () => {
const g = await index({
'src/a_export.ts': 'export const objTsExp = { tsExpM() { return 1; }, tsExpProp: () => 1 };\n',
'src/b_plain.ts': 'const objTsPlain = { tsPlainM() { return 1; }, tsPlainProp: () => 1 };\n',
'src/c_export.js': 'export const objJsExp = { jsExpM() { return 1; }, jsExpProp: () => 1 };\n',
'src/d_plain.js': 'const objJsPlain = { jsPlainM() { return 1; }, jsPlainProp: () => 1 };\n',
'src/e_iife.js': [
'(function () {',
' const objIife = { iifeM() { return 1; } };',
' window.WS = { wsM() { return objIife.iifeM(); } };',
'})();',
'',
].join('\n'),
'src/f_calls.js': [
'function helper() { return 2; }',
'var store = { shorthand() { return helper(); } };',
'function useStore() { return store.shorthand(); }',
'window.api = { load() { return store.shorthand(); } };',
'function viaWindow() { return window.WS.wsM(); }',
'function viaGlobal() { return WS.wsM(); }',
'',
].join('\n'),
});
for (const qn of [
'objTsExp::tsExpM', 'objTsExp::tsExpProp', 'objTsPlain::tsPlainM', 'objTsPlain::tsPlainProp',
'objJsExp::jsExpM', 'objJsExp::jsExpProp', 'objJsPlain::jsPlainM', 'objJsPlain::jsPlainProp',
'objIife::iifeM', 'window.WS::wsM', 'store::shorthand', 'window.api::load',
]) {
expect(node(g, qn).kind, qn).toBe('function');
}
// The member belongs to its owner, and `window.WS` is the global `WS`.
const plain = node(g, 'objTsPlain');
expect(g.getOutgoingEdges(plain.id).filter((e) => e.kind === 'contains').map((e) => g.getNode(e.target)!.name).sort())
.toEqual(['tsPlainM', 'tsPlainProp']);
expect(g.getNodesByName('WS').map((n) => `${n.kind} ${n.qualifiedName}`)).toEqual(['variable window.WS']);
// An exported literal's members stay exported, a plain one's do not.
expect(node(g, 'objTsExp::tsExpM').isExported).toBe(true);
expect(node(g, 'objTsPlain::tsPlainM').isExported).toBeFalsy();
// The call inside a member is the member's, not the constant's.
expect(callers(g, 'helper')).toEqual(['store::shorthand']);
expect(callers(g, 'objIife::iifeM')).toEqual(['window.WS::wsM']);
expect(callers(g, 'store::shorthand')).toEqual(['useStore', 'window.api::load']);
// `window.WS.wsM()`, and `WS.wsM()` from another classic script.
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal', 'viaWindow']);
});
it('resolves through the object the call names — never by the bare name', async () => {
const g = await index({
'app.js': [
'function init() { return "global"; }',
'var App = {',
' init: function () { this.render(); helpers.fmt(); },',
' render: function () { return 1; },',
'};',
'var helpers = { fmt() { return 2; } };',
'App.utils = { pad(s) { return s; } };',
'function boot() { App.init(); App.utils.pad("x"); init(); }',
'dw_page = { start() { App.render(); } };',
'',
].join('\n'),
'page.js': [
'function main() { App.init(); window.App.render(); dw_page.start(); App.utils.pad("y"); }',
'function shadow(App) { return App.init(); }',
'function bare() { return render(); }',
'',
].join('\n'),
});
expect(callees(g, 'boot')).toEqual(['App.utils::pad', 'App::init', 'init']);
expect(callees(g, 'App::init')).toEqual(['App::render', 'helpers::fmt']);
expect(callees(g, 'dw_page::start')).toEqual(['App::render']);
// Another script reaches the page's globals; a parameter of the same name does not.
expect(callees(g, 'main')).toEqual(['App.utils::pad', 'App::init', 'App::render', 'dw_page::start']);
expect(callees(g, 'shadow')).toEqual([]);
// `render()` alone is not `App.render`, and `init()` stays the global function.
expect(callees(g, 'bare')).toEqual([]);
expect(callers(g, 'init')).toEqual(['boot']);
});
it('never takes an object hung on a path, or one local to an IIFE, for a name elsewhere', async () => {
const g = await index({
// A Svelte project, where `$count` reads the store `count` — in a `.svelte` component.
'package.json': '{ "dependencies": { "svelte": "^4.0.0" } }\n',
'lib.js': [
'$.event.special.swipe = { setup: function () { return 1; } };',
'todos.model = { add: function () { return 2; } };',
'(function () { var n = { touch: function () { return 3; } }; n.touch(); })();',
'',
].join('\n'),
'widget.js': [
'var Widget = {',
' drag: function () { this.swipe(1); },',
' make: function () { return new this.model(); },',
'};',
'',
].join('\n'),
// Two compiled scripts, each with its own `$n`.
'gwt1.js': 'function $n(a) { this.a = a; }\nfunction make1() { return new $n(1); }\n',
'gwt2.js': 'function $n(a) { this.a = a; }\nfunction make2() { return new $n(2); }\n',
});
const edges = (qn: string) => g.getOutgoingEdges(node(g, qn).id).filter((e) => e.kind !== 'contains')
.map((e) => `${e.kind} ${g.getNode(e.target)!.qualifiedName} ${g.getNode(e.target)!.filePath}`);
// `this.swipe()` and `new this.model()` are not the objects at `$.event.special.swipe` and `todos.model`.
expect(edges('Widget::drag')).toEqual([]);
expect(edges('Widget::make')).toEqual([]);
// A plain script's `$n` is its own function, not a store read of the IIFE's `n`.
expect(edges('make1')).toEqual(['instantiates $n gwt1.js']);
expect(edges('make2')).toEqual(['instantiates $n gwt2.js']);
});
it('keeps each IIFE and function its own literal, and a module its private ones', async () => {
const g = await index({
'iife.js': [
'(function () { const Api = { run() { left(); } }; Api.run(); })();',
'(function () { const Api = { run() { right(); } }; Api.run(); })();',
'function left() {}',
'function right() {}',
'function outside() { return Api.run(); }',
'function a() { const local = { go() { left(); } }; local.go(); }',
'function b() { const local = { go() { right(); } }; local.go(); }',
'',
].join('\n'),
'mod.js': 'import { x } from "./x.js";\nconst Local = { run() { return x; } };\nexport function useLocal() { return Local.run(); }\n',
'other.js': 'import { y } from "./y.js";\nexport function nope() { return Local.run(y); }\n',
});
const [first, second] = g.getNodesByQualifiedName('Api::run').sort((p, q) => p.startLine - q.startLine);
expect(calls(g, first!.id, 'out')).toEqual(['left']);
expect(calls(g, second!.id, 'out')).toEqual(['right']);
// Each IIFE's `Api.run()` reaches its own literal (the file runs both).
expect(calls(g, first!.id, 'in')).toEqual(['iife.js']);
expect(calls(g, second!.id, 'in')).toEqual(['iife.js']);
// Outside both IIFEs, `Api` is neither of them — not even as a value read.
expect(callees(g, 'outside')).toEqual([]);
expect(g.getOutgoingEdges(node(g, 'outside').id).filter((e) => e.kind !== 'contains')).toEqual([]);
expect(callees(g, 'a')).toEqual(['a::local::go']);
expect(callees(g, 'b')).toEqual(['b::local::go']);
expect(callees(g, 'useLocal')).toEqual(['Local::run']);
expect(callees(g, 'nope')).toEqual([]);
});
it('gives arrow members and values the `this` of the method around the literal', async () => {
const g = await index({
'app.ts': [
'class Emitter { on(cb: () => void) { return cb; } }',
'export class App {',
' private emitter = new Emitter();',
' updateScene() { return 1; }',
' createApi() {',
' const api = {',
' updateScene: this.updateScene,',
' onChange: (cb: () => void) => this.emitter.on(cb),',
' refresh: () => this.updateScene(),',
' own() { return this.refresh(); },',
' };',
' return api;',
' }',
'}',
'',
].join('\n'),
});
// A value in a method's literal is that method's own reference, as before.
const createApi = node(g, 'App::createApi');
expect(g.getOutgoingEdges(createApi.id).filter((e) => e.kind === 'references')
.map((e) => g.getNode(e.target)!.qualifiedName)).toContain('App::updateScene');
// An arrow member's `this` is the class instance…
expect(callees(g, 'App::createApi::api::onChange')).toEqual(['Emitter::on']);
expect(callees(g, 'App::createApi::api::refresh')).toEqual(['App::updateScene']);
// …an object's own method's `this` is the object.
expect(callees(g, 'App::createApi::api::own')).toEqual(['App::createApi::api::refresh']);
});
it('keeps a cross-file member edge through edits of the defining script', async () => {
const g = await index({
'ns.js': 'window.WS = { a() { return 1; }, wsM() { return 2; } };\n',
'use.js': 'function viaGlobal() { return WS.wsM(); }\n',
});
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal']);
fs.writeFileSync(path.join(dir, 'ns.js'), '// moved\nwindow.WS = {\n wsM() { return 3; },\n};\n');
await g.sync();
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal']);
fs.writeFileSync(path.join(dir, 'ns.js'), 'window.WS = { renamed() { return 4; } };\n');
await g.sync();
expect(g.getNodesByQualifiedName('window.WS::wsM')).toEqual([]);
expect(callees(g, 'viaGlobal')).toEqual([]);
}, 30_000);
it('reaches an imported literal and a binding destructured off one', async () => {
const g = await index({
'api.js': [
'export const api = { get() { return 1; }, post() { return 2; } };',
'const { post } = api;',
'export function b() { return post(); }',
'export function c() { return get(); }',
'',
].join('\n'),
'use.js': [
'import { api } from "./api.js";',
'export function a() { return api.get(); }',
'',
].join('\n'),
});
expect(callees(g, 'a')).toEqual(['api::get']);
expect(callees(g, 'b')).toEqual(['api::post']);
// `get` was never destructured: a bare `get()` is not `api.get`.
expect(callees(g, 'c')).toEqual([]);
});
it('leaves CommonJS exports, prototypes, call arguments, data and minified bundles as they were', async () => {
const g = await index({
'shapes.js': [
'module.exports = { cjs() { return 1; } };',
'Foo.prototype = { proto() { return 1; } };',
'consume({ ephemeral() { return 1; } });',
'const data = { a: 1, b: [1, 2] };',
'this.state = { onThis() { return 1; } };',
'',
].join('\n'),
'vendor/lib.min.js': 'var a={b:function(){return 1},c:function(){return 2}};window.L={d:function(){return a.b()}};\n',
// A bundle not named so: its lines are what gives it away.
'dist/bundle.js': `${'var q={b:function(){return r(1,2)},c:function(s){return s}};window.M={d:function(){return q.b()}};'.repeat(60)}\n`.repeat(2),
'iife.js': '(function () { var e; if (!e) { e = { getItem: function () { return 1; } }; } })();\n',
});
// …and a plain name reassigned inside a function (a bundle's `e = {…}`) is a local.
for (const name of ['cjs', 'proto', 'ephemeral', 'onThis', 'b', 'c', 'd', 'getItem']) {
expect(g.getNodesByName(name), name).toEqual([]);
}
expect(node(g, 'data').kind).toBe('constant');
});
it('keeps a <script setup> literal\'s members under their owner', async () => {
const g = await index({
'Panel.vue': [
'<template><button @click="save">Save</button></template>',
'<script setup>',
'const handlers = { onSave() { persist(); } };',
'function persist() {}',
'function save() { handlers.onSave(); }',
'</script>',
'',
].join('\n'),
});
const owner = node(g, 'handlers');
const member = node(g, 'handlers::onSave');
expect(g.getIncomingEdges(member.id).filter((e) => e.kind === 'contains').map((e) => e.source)).toEqual([owner.id]);
expect(callees(g, 'handlers::onSave')).toEqual(['persist']);
expect(callees(g, 'save')).toEqual(['handlers::onSave']);
});
});