= {
+ 'package.json': JSON.stringify({ name: 'app', dependencies: { zod: '^4.0.0', mocha: '^10.0.0', react: '^19.0.0', '@tanstack/react-query': '^5.0.0' } }),
+ 'src/test/Mocker.ts': `export class Mocker {
+ get string(): string { return 'x'; }
+ record(): number { return 1; }
+}
+`,
+ 'src/test/xfail.ts': `export interface XFailFunction {
+ it: (title: string) => void;
+}
+`,
+ 'src/commands/CacheClear.ts': `export class CacheClearCommand {
+ describe = "Clears all data stored in query runner cache.";
+ run() { return 1; }
+}
+`,
+ 'src/utils.ts': `export function helper() { return 1; }
+`,
+ 'src/api.ts': `class ApiClient {
+ fetchUsers() { return []; }
+}
+export default new ApiClient();
+`,
+ 'src/schemas.ts': `import * as z from 'zod';
+import { z as zz } from 'zod';
+import * as utils from './utils';
+import api from './api';
+
+export function schemas() {
+ const a = z.string();
+ const b = zz.record();
+ const c = utils.helper();
+ const d = api.fetchUsers();
+ return [a, b, c, d];
+}
+`,
+ 'src/schemas.test.ts': `import { useQuery } from '@tanstack/react-query';
+describe('schemas', () => {
+ it('works', () => { useQuery({}); });
+});
+`,
+ 'src/hooks.ts': `export function useQuery(key: string) { return key; }
+`,
+ 'src/factory.ts': `export function createRootHooks() {
+ function useQuery(input: unknown) { return input; }
+ return { useQuery };
+}
+`,
+ 'src/useAuth.ts': `export function useAuth() { return { user: null }; }
+`,
+ 'src/App.tsx': `import { useQuery } from '@tanstack/react-query';
+import { useAuth } from './useAuth';
+export function App() {
+ const q = useQuery({ queryKey: ['a'] });
+ const auth = useAuth();
+ return {String(q)}{String(auth)}
;
+}
+`,
+};
+
+describe('JS/TS: a member picked by name alone', () => {
+ it('is not what a namespace import, an outside package or a bare call reaches', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-member-guess-'));
+ roots.push(root);
+ for (const [rel, content] of Object.entries(FILES)) {
+ fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
+ fs.writeFileSync(path.join(root, rel), content);
+ }
+ const cg = await CodeGraph.init(root, { index: true });
+ try {
+ const callsFrom = (file: string): string[] => {
+ const ids = cg.getNodesInFile(file).map((n) => n.id);
+ return cg
+ .getOutgoingEdgesFrom(ids, ['calls'])
+ .map((e) => cg.getNode(e.target)?.qualifiedName ?? '?')
+ .sort();
+ };
+ const fromSchemas = callsFrom('src/schemas.ts');
+ expect(fromSchemas).not.toContain('Mocker::string');
+ expect(fromSchemas).not.toContain('Mocker::record');
+ expect(fromSchemas).toContain('helper');
+ expect(fromSchemas).toContain('ApiClient::fetchUsers');
+ const fromTest = callsFrom('src/schemas.test.ts');
+ expect(fromTest).not.toContain('XFailFunction::it');
+ expect(fromTest).not.toContain('CacheClearCommand::describe');
+ // Imported from a package: not the project's own `useQuery`.
+ expect(fromTest).not.toContain('useQuery');
+ // The same in a React file, where hooks resolve by framework rules — and a
+ // hook imported from the project still resolves.
+ const fromApp = callsFrom('src/App.tsx');
+ expect(fromApp.filter((q) => q.endsWith('useQuery'))).toEqual([]);
+ expect(fromApp).toContain('useAuth');
+ } finally {
+ cg.close();
+ }
+ });
+
+ it('applies to Vue and Svelte files, and an unknown alias is still the project', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-sfc-guess-'));
+ roots.push(root);
+ const files: Record = {
+ 'frontend/package.json': JSON.stringify({ name: 'frontend', dependencies: { 'vue-i18n': '*', dayjs: '*', '@app/shared': 'workspace:*' } }),
+ 'shared/index.ts': `export function formatUrl(u: string) { return u; }
+`,
+ 'frontend/types/context.ts': `export interface ProvidersContext {
+ t: (key: string) => string;
+}
+`,
+ 'frontend/test/mocks.ts': `export function dayjs() { return 0; }
+`,
+ 'frontend/composables/store.ts': `export function useLabelStore() { return {}; }
+`,
+ 'frontend/composables/use-labels.ts': `import { useLabelStore } from '~/composables/store';
+export function useLabels() { return useLabelStore(); }
+`,
+ 'frontend/pages/labels.vue': `
+{{ title }}
+`,
+ 'docs/package.json': JSON.stringify({ name: 'docs', devDependencies: { '@sveltejs/kit': '*' } }),
+ 'docs/src/lib/design.ts': `export function useDesignSystem() { return 1; }
+`,
+ 'docs/src/lib/router.ts': `export function goto(path: string) { return path; }
+`,
+ 'docs/src/lib/use.ts': `import { useDesignSystem } from '$lib/design';
+export const current = () => useDesignSystem();
+`,
+ 'docs/src/lib/instance.js': `export const DEV = true;
+if (DEV) {
+ const fetch = globalThis.fetch;
+ globalThis.fetch = async (info) => fetch(info);
+}
+`,
+ 'docs/src/lib/Map.svelte': `map
+`,
+ 'docs/src/routes/+page.svelte': `
+
+`,
+ // Deno: the import map says `@std/assert` comes from JSR.
+ 'deno/deno.json': JSON.stringify({ imports: { '@std/assert': 'jsr:@std/assert@^1', 'app/': '../src/' } }),
+ 'deno/test/assert.ts': `export function assertEquals(a: unknown, b: unknown) { return a === b; }
+`,
+ 'deno/app.test.ts': `import { assertEquals } from '@std/assert';
+assertEquals(1, 1);
+`,
+ };
+ for (const [rel, content] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
+ fs.writeFileSync(path.join(root, rel), content);
+ }
+ const cg = await CodeGraph.init(root, { index: true });
+ try {
+ const callsFrom = (file: string): string[] => {
+ const ids = cg.getNodesInFile(file).map((n) => n.id);
+ return cg
+ .getOutgoingEdgesFrom(ids, ['calls'])
+ .map((e) => cg.getNode(e.target)?.qualifiedName ?? '?')
+ .sort();
+ };
+ const fromVue = callsFrom('frontend/pages/labels.vue');
+ expect(fromVue).not.toContain('ProvidersContext::t');
+ expect(fromVue).not.toContain('dayjs');
+ expect(fromVue).toContain('useLabelStore');
+ // A `workspace:*` dependency is in the repository.
+ expect(fromVue).toContain('formatUrl');
+ expect(callsFrom('frontend/composables/use-labels.ts')).toContain('useLabelStore');
+ const fromSvelte = callsFrom('docs/src/routes/+page.svelte');
+ expect(fromSvelte).toContain('useDesignSystem');
+ expect(fromSvelte).not.toContain('goto');
+ expect(fromSvelte).not.toContain('fetch');
+ expect(callsFrom('deno/app.test.ts')).not.toContain('assertEquals');
+ // A built-in's name imported from the project is the import.
+ const svelteIds = cg.getNodesInFile('docs/src/routes/+page.svelte').map((n) => n.id);
+ const imported = cg.getOutgoingEdgesFrom(svelteIds, ['imports']).map((e) => cg.getNode(e.target)!.filePath);
+ expect(imported).toContain('docs/src/lib/Map.svelte');
+ expect(callsFrom('docs/src/lib/use.ts')).toContain('useDesignSystem');
+ } finally {
+ cg.close();
+ }
+ });
+});
diff --git a/__tests__/js-object-literal-members.test.ts b/__tests__/js-object-literal-members.test.ts
new file mode 100644
index 0000000000..f24270e39f
--- /dev/null
+++ b/__tests__/js-object-literal-members.test.ts
@@ -0,0 +1,320 @@
+/**
+ * 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): Promise {
+ 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 ',
+ '',
+ ].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']);
+ });
+});
diff --git a/__tests__/js-resolution-work.test.ts b/__tests__/js-resolution-work.test.ts
new file mode 100644
index 0000000000..1849f91a40
--- /dev/null
+++ b/__tests__/js-resolution-work.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { matchCollapsedObjectCall } from '../src/resolution/name-matcher';
+import { stripCommentsForRegex } from '../src/resolution/strip-comments';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+
+// Pass-through, so a test can count how often text is stripped.
+vi.mock('../src/resolution/strip-comments', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, stripCommentsForRegex: vi.fn(actual.stripCommentsForRegex) };
+});
+
+/**
+ * #2334: resolving JS/TS does a bounded amount of work per file. In a file
+ * with destructuring, the check for a call through a destructured name
+ * stripped and scanned every line above each bare call; the check for a name
+ * the calling function binds itself stripped that function's lines above each
+ * reference again. A bundled library took time in the square of its size.
+ * Counted, never timed.
+ */
+describe('JS resolution work (#2334)', () => {
+ let tmpDir: string | undefined;
+ let cg: CodeGraph | undefined;
+
+ afterEach(() => {
+ vi.mocked(stripCommentsForRegex).mockClear();
+ cg?.close();
+ cg = undefined;
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
+ tmpDir = undefined;
+ });
+
+ async function index(files: Record): Promise {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-work-'));
+ for (const [file, content] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(tmpDir, file)), { recursive: true });
+ fs.writeFileSync(path.join(tmpDir, file), content);
+ }
+ cg = CodeGraph.initSync(tmpDir);
+ await cg.indexAll();
+ return cg;
+ }
+
+ /** How often a non-empty start of `text` was stripped as `lang`. */
+ const strips = (lang: string, text: string) => vi.mocked(stripCommentsForRegex).mock.calls
+ .filter(([stripped, as]) => as === lang && stripped.length > 0 && text.startsWith(stripped)).length;
+
+ const callers = (graph: CodeGraph, file: string, name: string) => {
+ const target = graph.getNodesByName(name).find((n) => n.filePath === file && n.kind === 'function')!;
+ return [...new Set(graph.getIncomingEdges(target.id).filter((e) => e.kind === 'calls')
+ .map((e) => graph.getNode(e.source)?.name))];
+ };
+
+ it('reads a file with destructuring once, not once per call', async () => {
+ const CALLS = 40;
+ const app = `import { useAuth } from './auth';\n` +
+ `const { login } = useAuth();\n` +
+ Array.from({ length: CALLS }, (_, i) => `function step${i}() { return ${i}; }\n`).join('') +
+ `export function run() {\n` +
+ Array.from({ length: CALLS }, (_, i) => ` step${i}();\n`).join('') +
+ // A division the blanking reads as a regex literal around the call.
+ ` return (${CALLS}) / login() / 2;\n}\n`;
+ const graph = await index({
+ 'auth.js': 'export function useAuth() {\n function login() {\n return 1;\n }\n return { login };\n}\n',
+ 'app.js': app,
+ });
+ // The call through the destructured name reaches the function the hook returns.
+ expect(callers(graph, 'auth.js', 'login')).toEqual(['run']);
+ expect(callers(graph, 'app.js', 'step7')).toEqual(['run']);
+ // Each bare call was checked; the file was stripped for that once, not once per call.
+ expect(strips('typescript', app)).toBeLessThan(CALLS / 4);
+ });
+
+ it("strips a function's lines once, not once per reference", async () => {
+ const LINES = 40;
+ const host = `export function host() {\n` +
+ Array.from({ length: LINES }, (_, i) => ` helper(${i});\n`).join('') + `}\n`;
+ const graph = await index({ 'lib.js': `export function helper(n) {\n return n;\n}\n${host}` });
+ // Each call resolved, so each asked whether `host` binds `helper` itself.
+ expect(callers(graph, 'lib.js', 'helper')).toEqual(['host']);
+ expect(strips('javascript', host)).toBeLessThan(LINES / 4);
+ });
+});
+
+/**
+ * A call the extractor records by its bare name has its receiver read back
+ * from the source, from the ref's column on. A minified bundle is one long
+ * line, and joining it with the lines after it copied all of it for every
+ * call on it: about 1 MB per call on go-ethereum's graphiql.min.js. Counted,
+ * never timed.
+ */
+describe('a receiver read back from a minified line', () => {
+ /** How many characters arrays were joined into while `run` ran. */
+ function joinedDuring(run: () => void): number {
+ const join = Array.prototype.join;
+ let chars = 0;
+ Array.prototype.join = function (this: unknown[], separator?: string): string {
+ const text = join.call(this, separator);
+ chars += text.length;
+ return text;
+ };
+ try {
+ run();
+ } finally {
+ Array.prototype.join = join;
+ }
+ return chars;
+ }
+
+ /**
+ * Runs `refs` through the check for a call written on `window.App…`, which
+ * looks up the object the receiver it read back names.
+ */
+ function readBack(source: string, refs: UnresolvedRef[]): { holders: string[]; joined: number } {
+ const lines = source.split('\n');
+ const holders: string[] = [];
+ const context: ResolutionContext = {
+ getNodesInFile: () => [],
+ getNodesByName: (name) => {
+ holders.push(name);
+ return [];
+ },
+ getNodesByQualifiedName: () => [],
+ getNodesByKind: () => [],
+ getNodesByLowerName: () => [],
+ fileExists: () => false,
+ readFile: () => source,
+ getFileLines: () => lines,
+ getProjectRoot: () => '',
+ getAllFiles: () => [],
+ getImportMappings: () => [],
+ };
+ const joined = joinedDuring(() => {
+ for (const ref of refs) matchCollapsedObjectCall(ref, context);
+ });
+ return { holders, joined };
+ }
+
+ const call = (name: string, line: number, column: number): UnresolvedRef => ({
+ fromNodeId: 'bundle', referenceName: name, referenceKind: 'calls', line, column,
+ filePath: 'bundle.min.js', language: 'javascript',
+ });
+
+ it('reads every receiver on the line without copying the line once per call', () => {
+ const CALLS = 300;
+ const refs: UnresolvedRef[] = [];
+ let line = '!function(){';
+ for (let i = 0; i < CALLS; i++) {
+ refs.push(call(`m${i}`, 2, line.length));
+ line += `window.App${i}.m${i}(${i});`;
+ }
+ line += '}();';
+ const { holders, joined } = readBack(`/*! bundle */\n${line}\n//# sourceMappingURL=bundle.min.js.map`, refs);
+ // Each call's receiver was read back...
+ expect(holders).toEqual(refs.map((_, i) => `App${i}`));
+ // ...and all of them together copied less than the line once.
+ expect(joined).toBeLessThan(line.length);
+ });
+
+ it('reads on past the line when the line alone does not settle the call', () => {
+ // The call continues on the next lines.
+ expect(readBack('window.App\n .go\n (1);', [call('go', 1, 0)]).holders).toEqual(['App']);
+ // The line holds `foo(`, but the call is `foo[…](3)`, whose subscript runs onto the next line.
+ expect(readBack('window.App.foo[foo(1),\n2](3);', [call('foo', 1, 0)]).holders).toEqual(['App']);
+ // A column past the end of its line reads on from the next one.
+ expect(readBack('x\nwindow.App.go(1);', [call('go', 1, 2)]).holders).toEqual(['App']);
+ });
+});
diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts
index 5c8465a900..a5d669e7b6 100644
--- a/__tests__/jsx-child-disambiguation.test.ts
+++ b/__tests__/jsx-child-disambiguation.test.ts
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
+import { jsxCode } from '../src/resolution/jsx-code';
/**
* A JSX tag names ONE component, and the file it is written in says which:
@@ -129,3 +130,1105 @@ export function Header() { return
; }
expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']);
});
});
+
+/**
+ * The pass reads tag names off a parent's source with a pattern, and two
+ * kinds of name it reads that way are no other file's component.
+ *
+ * - A type argument: ` …>` or `useState()`
+ * writes a type's name right after another name. outline's document list
+ * rendered its `Document` model class, its settings tables the `User` and
+ * `Group` classes.
+ * - A name the parent binds itself, as a `const` or a parameter, is that local
+ * wherever its tags sit. outline's menus pick a Radix part into `const
+ * Content = variant === 'dropdown' ? DropdownMenu.SubContent :
+ * ContextMenu.SubContent` and rendered the command bar's styled `Content`
+ * instead. A local is no node, so it renders nothing, unless the parent
+ * declares a component of that name inside itself.
+ */
+describe('JSX child: a name the parent writes only as a type, or binds itself', () => {
+ let dir: string;
+ let cg: any;
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-local-'));
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
+ });
+
+ afterEach(() => {
+ cg?.close?.();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ const write = (rel: string, body: string) => {
+ const p = path.join(dir, rel);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, body);
+ };
+
+ async function index(): Promise {
+ cg = await CodeGraph.init(dir, { silent: true });
+ await cg.indexAll();
+ }
+
+ /** `name file:line` of each node a jsx-render edge out of `parent` points at. */
+ const renders = (parent: string): string[] =>
+ cg.db.db
+ .prepare(
+ `SELECT t.name || ' ' || t.file_path || ':' || t.start_line AS r FROM edges e
+ JOIN nodes s ON s.id = e.source
+ JOIN nodes t ON t.id = e.target
+ WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
+ ORDER BY r`
+ )
+ .all(parent)
+ .map((row: any) => row.r);
+
+ it('renders nothing for a name written only as a type argument or parameter', async () => {
+ write('app/models/Document.ts', 'export default class Document {\n title = "";\n}\n');
+ write('app/models/User.ts', 'export default class User {\n name = "";\n}\n');
+ write(
+ 'app/components/PaginatedList.tsx',
+ 'export function PaginatedList({ items }: { items: T[] }) {\n return ;\n}\n'
+ );
+ write(
+ 'app/components/Badge.tsx',
+ 'import * as React from "react";\nexport class Badge extends React.Component {\n render() {\n return ;\n }\n}\n'
+ );
+ write(
+ 'app/components/DocumentList.tsx',
+ `import * as React from 'react';
+import Document from '../models/Document';
+import User from '../models/User';
+import { Badge } from './Badge';
+import { PaginatedList } from './PaginatedList';
+
+export function DocumentList({ items }: { items: Document[] }) {
+ const [owner] = React.useState(null);
+ const badge = React.useRef(null);
+ return (
+
+ );
+}
+`
+ );
+ // bulletproof-react's table: a generic arrow function's type parameter.
+ write('app/profile/entry.tsx', 'export function Entry() {\n return ;\n}\n');
+ write(
+ 'app/components/Table.tsx',
+ `type BaseEntity = { id: string };
+
+export const Table = ({ data }: { data: Entry[] }) => {
+ return ;
+};
+`
+ );
+ await index();
+ // `Badge` is written as a type argument too, but a tag of it is enough.
+ // `` is a tag that passes a type argument: it
+ // renders PaginatedList, and its `Document` stays a type.
+ expect(renders('DocumentList')).toEqual([
+ 'Badge app/components/Badge.tsx:2',
+ 'PaginatedList app/components/PaginatedList.tsx:1',
+ ]);
+ expect(renders('Table')).toEqual([]);
+ });
+
+ it('renders nothing for a name the parent binds itself as a const or a parameter', async () => {
+ write(
+ 'app/components/CommandBarItem.tsx',
+ 'export function Content() {\n return ;\n}\n\nexport function Icon() {\n return ;\n}\n'
+ );
+ write('shared/editor/Widget.tsx', 'export function Widget() {\n return ;\n}\n');
+ write('app/components/Portal.tsx', 'export function Portal({ children }) {\n return {children}
;\n}\n');
+ write(
+ 'app/components/Menu.tsx',
+ `import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import * as ContextMenu from '@radix-ui/react-context-menu';
+
+const Portal = DropdownMenu.Portal;
+
+export function SubMenuContent({ variant, children }) {
+ const Portal =
+ variant === 'dropdown' ? DropdownMenu.Portal : ContextMenu.Portal;
+ const Content =
+ variant === 'dropdown'
+ ? DropdownMenu.SubContent
+ : ContextMenu.SubContent;
+ return (
+
+