mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
f40db4b9cc
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>
118 lines
5.3 KiB
TypeScript
118 lines
5.3 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
|
|
let dir: string;
|
|
let cg: CodeGraph | undefined;
|
|
afterEach(() => {
|
|
cg?.destroy();
|
|
cg = undefined;
|
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const families = {
|
|
array: ['map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every', 'push', 'pop', 'slice', 'splice', 'sort', 'flatMap', 'includes', 'join'],
|
|
collection: ['get', 'set', 'has', 'add', 'delete', 'clear', 'keys', 'values', 'entries'],
|
|
string: ['trim', 'split', 'replace', 'replaceAll', 'match', 'matchAll', 'search', 'startsWith', 'endsWith', 'substring', 'toLowerCase', 'toUpperCase'],
|
|
promise: ['then', 'catch', 'finally'],
|
|
function: ['call', 'apply', 'bind'],
|
|
event: ['on', 'once', 'off', 'emit', 'addListener', 'removeListener', 'removeAllListeners', 'addEventListener', 'removeEventListener', 'dispatchEvent'],
|
|
iterator: ['next', 'return', 'throw'],
|
|
};
|
|
|
|
describe.each(['ts', 'tsx', 'js', 'jsx'])('built-in method calls in %s (#1987)', (ext) => {
|
|
it('does not guess project methods for unknown receivers, including class-name overlap', async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1987-'));
|
|
const declarations: string[] = [];
|
|
const calls: string[] = [];
|
|
for (const [family, methods] of Object.entries(families)) {
|
|
// Receiver and class share a word; this is still no evidence of its type.
|
|
declarations.push(`export class ${family}Wrapper {\n${methods.map(m => ` ${m}() {}\n`).join('')}}\n`);
|
|
methods.forEach(m => calls.push(`export function ${family}_${m}(${family}) { ${family}.${m}(); }\n`));
|
|
}
|
|
fs.writeFileSync(path.join(dir, `decoys.${ext}`), declarations.join(''));
|
|
fs.writeFileSync(path.join(dir, `calls.${ext}`), calls.join(''));
|
|
cg = await CodeGraph.init(dir, { index: true });
|
|
for (const caller of cg.getNodesByKind('function').filter(n => n.filePath === `calls.${ext}`)) {
|
|
expect(cg.getCallees(caller.id).map(c => c.node.qualifiedName), caller.name).toEqual([]);
|
|
}
|
|
expect(cg.getNodesByKind('function').filter(n => n.filePath === `calls.${ext}`)).toHaveLength(calls.length);
|
|
});
|
|
|
|
it('retains every method family on a constructed or typed project receiver', async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1987-'));
|
|
const methods = [...new Set(Object.values(families).flat())];
|
|
const typed = ext === 'ts' || ext === 'tsx';
|
|
fs.writeFileSync(path.join(dir, `project.${ext}`), `
|
|
export class Project {
|
|
${methods.map(m => ` ${m}() {}\n`).join('')}
|
|
}
|
|
export function useConstructed() {
|
|
const receiver = new Project();
|
|
${methods.map(m => ` receiver.${m}();\n`).join('')}
|
|
}
|
|
${typed ? `export function useTyped(receiver: Project) {
|
|
${methods.map(m => ` receiver.${m}();\n`).join('')}
|
|
}` : ''}
|
|
`);
|
|
cg = await CodeGraph.init(dir, { index: true });
|
|
for (const name of typed ? ['useConstructed', 'useTyped'] : ['useConstructed']) {
|
|
const caller = cg.getNodesByKind('function').find(n => n.name === name)!;
|
|
expect(cg.getCallees(caller.id).filter(c => c.node.kind === 'method').map(c => c.node.name).sort())
|
|
.toEqual([...methods].sort());
|
|
}
|
|
});
|
|
|
|
it('rejects built-in values but retains constructed, imported, literal and class receivers', async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1987-'));
|
|
const typed = ext === 'ts' || ext === 'tsx';
|
|
fs.writeFileSync(path.join(dir, `cart.${ext}`), `
|
|
export class Cart {
|
|
map() {}
|
|
add() {}
|
|
get() {}
|
|
static bind() {}
|
|
self() { this.add(); }
|
|
}
|
|
export const api = { map() {} };
|
|
export class LRUCache { get() {} }
|
|
`);
|
|
fs.writeFileSync(path.join(dir, `calls.${ext}`), `
|
|
import { Cart, api } from './cart';
|
|
export function tidy(list${typed ? ': string[]' : ''}) { list.map(); }
|
|
export function cached() {
|
|
const cache = new Map();
|
|
cache.get('x');
|
|
}
|
|
export function unknownCache(cache) { cache.get('x'); }
|
|
export function literalArray() { const cart = []; cart.map(); }
|
|
export function literalString() { const cart = 'x'; cart.trim(); }
|
|
export function constructed() {
|
|
const cart = new Cart();
|
|
cart.add();
|
|
}
|
|
export function imported() { Cart.bind(); }
|
|
export function objectLiteral() { api.map(); }
|
|
export const local = { map() {} };
|
|
export function localLiteral() { local.map(); }
|
|
${typed ? 'export function typed(cart: Cart) { cart.map(); }' : ''}
|
|
`);
|
|
cg = await CodeGraph.init(dir, { index: true });
|
|
const callees = (name: string) => {
|
|
const fn = cg!.getNodesByKind('function').find(n => n.name === name)!;
|
|
expect(fn, name).toBeDefined();
|
|
return cg!.getCallees(fn.id).filter(({ edge }) => edge.kind === 'calls').map(c => c.node.qualifiedName);
|
|
};
|
|
for (const name of ['tidy', 'cached', 'unknownCache', 'literalArray', 'literalString']) expect(callees(name), name).toEqual([]);
|
|
expect(callees('constructed')).toContain('Cart::add');
|
|
expect(callees('imported')).toContain('Cart::bind');
|
|
expect(callees('objectLiteral')).toEqual(['api::map']);
|
|
expect(callees('localLiteral')).toEqual(['local::map']);
|
|
if (typed) expect(callees('typed')).toEqual(['Cart::map']);
|
|
const self = cg.getNodesByKind('method').find(n => n.qualifiedName === 'Cart::self')!;
|
|
expect(cg.getCallees(self.id).map(c => c.node.qualifiedName)).toEqual(['Cart::add']);
|
|
});
|
|
});
|