Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- JavaScript and TypeScript calls through an imported constructor alias now reach that class's own or inherited method, and an overridden method's explicit superclass call reaches the parent while genuine recursion remains linked. Existing unaliased receiver inference is preserved, and source preprocessing is deferred until needed; re-index after upgrading. (#2497)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused.
- In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading.
Expand Down
70 changes: 70 additions & 0 deletions __tests__/js-constructor-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, 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 { matchJsSuperCall } from '../src/resolution/js-constructor-methods';
import * as stripping from '../src/resolution/strip-comments';
import type { ResolutionContext } from '../src/resolution/types';

let root: string | undefined;
let graph: CodeGraph | undefined;
afterEach(() => { vi.restoreAllMocks(); graph?.close(); if (root) fs.rmSync(root, { recursive: true, force: true }); });
async function index(files: Record<string, string>) {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-constructor-regressions-'));
for (const [file, source] of Object.entries(files)) fs.writeFileSync(path.join(root, file), source);
graph = await CodeGraph.init(root, { index: true });
return graph;
}
function targets(cg: CodeGraph, name: string) {
const caller = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find(n => n.qualifiedName === name)!;
return cg.getOutgoingEdges(caller.id).filter(e => e.kind === 'calls').map(e => cg.getNode(e.target)!.qualifiedName);
}
it('preserves both separately scoped unaliased receiver branches', async () => {
const cg = await index({ 'types.js': 'export class A { extract() {} } export class B { extract() {} }',
'consumer.js': "import {A,B} from './types';\nfunction invoke(flag) {\n if (flag) {\n const extractor = new A();\n return extractor.extract();\n } else {\n const extractor = new B();\n return extractor.extract();\n }\n}" });
expect(targets(cg, 'invoke')).toEqual(expect.arrayContaining(['A::extract', 'B::extract']));
});
it('preserves a real unaliased instance returned through destructuring to an anonymous callback', async () => {
const cg = await index({ 'pool.ts': 'export class Pool { requestParse() {} destroy() {} }',
'consumer.ts': "import {Pool} from './pool';\nfunction makePool() { const pool = new Pool(); return {pool}; }\nit('example', () => {\n const {pool} = makePool();\n pool.requestParse();\n pool.destroy();\n});" });
const caller = cg.getNodesInFile('consumer.ts').find(n => n.kind === 'file')!;
const calls = cg.getOutgoingEdges(caller.id).filter(e => e.kind === 'calls');
expect(calls).toContainEqual(expect.objectContaining({ target: cg.getNodesByKind('method').find(n => n.name === 'requestParse')!.id, line: 5 }));
expect(calls).toContainEqual(expect.objectContaining({ target: cg.getNodesByKind('method').find(n => n.name === 'destroy')!.id, line: 6 }));
});
it('does not strip or duplicate source for ordinary non-super calls', () => {
const source = 'function invoke() { helper(); }';
const raw = [source];
const context = { getFileLines: () => raw, readFile: () => { throw new Error('Source already available'); } } as unknown as ResolutionContext;
const spy = vi.spyOn(stripping, 'stripCommentsForRegex');
for (let i = 0; i < 2000; i++) expect(matchJsSuperCall({ fromNodeId: 'caller', referenceName: 'helper', referenceKind: 'calls', filePath: 'example.js', language: 'javascript', line: 1, column: source.indexOf('helper') }, context)).toBeUndefined();
expect(spy).not.toHaveBeenCalled();
});
it('does not call a static method on a constructed instance', async () => {
const cg = await index({ 'left.js': 'export class Left { static show() {} }',
'consumer.js': "import {Left as Known} from './left'; function invoke() { const item = new Known(); return item.show(); }" });
expect(targets(cg, 'invoke')).not.toContain('Left::show');
});
it('does not use an implements class as the runtime superclass', async () => {
const cg = await index({ 'shape.ts': 'export class Shape { run() {} }',
'consumer.ts': "import {Ext} from 'external'; import {Shape} from './shape'; class C extends Ext implements Shape { run() { return super.run(); } }" });
expect(targets(cg, 'C::run')).not.toContain('Shape::run');
});
it('resolves a static super call to the static parent method', async () => {
const cg = await index({ 'example.js': 'class Base { static run() {} } class C extends Base { static run() { return super.run(); } }' });
expect(targets(cg, 'C::run')).toContain('Base::run');
});
it('strips source once for thousands of super call sites', () => {
const count = 4000;
const source = 'class C extends Base { run() {\n' + ' super.run();\n'.repeat(count) + '} }\n';
const cls = { id: 'c', kind: 'class', name: 'C', qualifiedName: 'C', filePath: 'example.js', startLine: 1, endLine: count + 3, startColumn: 0, endColumn: 4 };
const parent = { ...cls, id: 'base', name: 'Base', qualifiedName: 'Base', filePath: 'base.js' };
const method = { ...parent, id: 'run', kind: 'method', name: 'run', qualifiedName: 'Base::run', startLine: 1, endLine: 1, isStatic: false };
const context = { getFileLines: () => source.split('\n'), readFile: () => source, getNodesInFile: () => [cls],
getSuperclassNodes: (id: string) => id === 'c' ? [parent] : [], getMethodMatches: () => [method] } as unknown as ResolutionContext;
const spy = vi.spyOn(stripping, 'stripCommentsForRegex');
for (let i = 0; i < count; i++) expect(matchJsSuperCall({ fromNodeId: 'caller', referenceName: 'run', referenceKind: 'calls', filePath: 'example.js', language: 'javascript', line: i + 2, column: 2 }, context)?.targetNodeId).toBe('run');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0]![0].length).toBe(source.length);
});
137 changes: 137 additions & 0 deletions __tests__/js-constructor-super.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 root: string | undefined;
let graph: CodeGraph | undefined;
afterEach(() => {
graph?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
async function project(files: Record<string, string>): Promise<CodeGraph> {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-constructors-'));
for (const [file, source] of Object.entries(files)) fs.writeFileSync(path.join(root, file), source);
graph = await CodeGraph.init(root, { index: true });
return graph;
}
function callees(cg: CodeGraph, name: string): string[] {
const caller = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find(n => n.name === name)!;
return cg.getOutgoingEdges(caller.id).filter(e => e.kind === 'calls')
.map(e => { const n = cg.getNode(e.target)!; return `${n.filePath}:${n.qualifiedName}`; }).sort();
}

describe.each(['js', 'ts', 'jsx', 'tsx'])('imported constructor identity (%s)', ext => {
it.each([false, true])('follows a named alias with a competing method = %s', decoy => {
return project({
['left.' + ext]: 'export class Left { show() { return "left"; } }',
...(decoy ? { ['right.' + ext]: 'export class Right { show() { return "right"; } }' } : {}),
['consumer.' + ext]: `import { Left as Known } from './left'; export function invoke() { const item = new Known(); return item.show(); }`,
}).then(cg => expect(callees(cg, 'invoke')).toContain(`left.${ext}:Left::show`));
});

it('pins an unaliased import to its declaring file even with same-named classes', async () => {
const cg = await project({
['left.' + ext]: 'export class Shared { show() { return "left"; } }',
['right.' + ext]: 'export class Shared { show() { return "right"; } }',
['consumer.' + ext]: `import { Shared } from './left'; export function invoke() { const item = new Shared(); return item.show(); }`,
});
expect(callees(cg, 'invoke')).toContain(`left.${ext}:Shared::show`);
expect(callees(cg, 'invoke')).not.toContain(`right.${ext}:Shared::show`);
});

it.each([
'export function invoke(Known) { const item = new Known(); return item.show(); }',
'export function invoke({ Known }) { const item = new Known(); return item.show(); }',
'export function invoke({ ctor: Known }) { const item = new Known(); return item.show(); }',
'export function invoke() { try { throw unknown; } catch (Known) { const item = new Known(); return item.show(); } }',
'export function invoke() { return values.map(Known => { const item = new Known(); return item.show(); }); }',
'export function invoke() { const Known = factory(); const item = new Known(); return item.show(); }',
'export function invoke() { let item = new Known(); item = factory(); return item.show(); }',
'export function invoke() { let item = new Known(); item += 1; return item.show(); }',
'export function invoke() { let item = new Known(); item ||= factory(); return item.show(); }',
'export function invoke() { let item = new Known(); [item] = factory(); return item.show(); }',
'export function invoke() { let item = new Known(); const msg = `${item = factory()}`; return item.show(); }',
'export function invoke(item) { return item.show(); }',
])('does not turn an unknown or shadowed receiver into the imported class: %s', body => {
return project({
['left.' + ext]: 'export class Left { show() { return "left"; } }',
['right.' + ext]: 'export class Right { show() { return "right"; } }',
['consumer.' + ext]: `import { Left as Known } from './left'; ${body}`,
}).then(cg => expect(callees(cg, 'invoke').filter(n => n.endsWith('::show'))).toEqual([]));
});

it('does not select a decoy method absent from the imported class', async () => {
const cg = await project({
['left.' + ext]: 'export class Left {}',
['right.' + ext]: 'export class Right { show() {} }',
['consumer.' + ext]: `import { Left as Known } from './left'; export function invoke() { const item = new Known(); return item.show(); }`,
});
expect(callees(cg, 'invoke').filter(n => n.endsWith('::show'))).toEqual([]);
});

it('follows a default import, a re-export and the exact superclass', async () => {
const cg = await project({
['base.' + ext]: 'export class Base { show() {} }',
['left.' + ext]: `import { Base } from './base'; export default class Left extends Base {}`,
['barrel.' + ext]: `export { default as Known } from './left';`,
['decoy.' + ext]: 'export class Base { show() {} }',
['consumer.' + ext]: `import { Known } from './barrel'; export function invoke() { const item = new Known(); return item.show(); }`,
});
expect(callees(cg, 'invoke')).toContain(`base.${ext}:Base::show`);
expect(callees(cg, 'invoke')).not.toContain(`decoy.${ext}:Base::show`);
});

it('retains a known local class shadowing the constructor import', async () => {
const cg = await project({
['left.' + ext]: 'export class Left { show() {} }',
['consumer.' + ext]: `import { Left as Known } from './left'; export function invoke() { class Known { show() {} } const item = new Known(); return item.show(); }`,
});
expect(callees(cg, 'invoke').some(n => n.startsWith('consumer.' + ext + ':') && n.endsWith('Known::show'))).toBe(true);
expect(callees(cg, 'invoke')).not.toContain(`left.${ext}:Left::show`);
});
});

it('preserves calls through an imported TypeScript parameter type', async () => {
const cg = await project({
'left.ts': 'export class Left { show() {} }',
'consumer.ts': `import { Left } from './left'; export function invoke(item: Left) { return item.show(); }`,
});
expect(callees(cg, 'invoke')).toContain('left.ts:Left::show');
});

describe.each(['js', 'ts', 'jsx', 'tsx'])('explicit superclass calls (%s)', ext => {
it.each(['run() { return super.run(); }', 'work() { return super.run(); }', 'run() { return super\n .run(); }', 'run() { return super\n\n\n\n\n .run(); }', 'run() { return `${super.run()}`; }', 'run() { return super /* parent */ .run(); }'])('reaches the parent: %s', body => {
return project({
['base.' + ext]: 'export class Base { run() { return 1; } }',
['decoy.' + ext]: 'export class Base { run() { return 2; } }',
['child.' + ext]: `import { Base } from './base'; export class Child extends Base { ${body} }`,
}).then(cg => {
// Locate the child explicitly: Base also declares run.
const child = cg.getNodesInFile('child.' + ext).find(n => n.kind === 'method')!;
const edges = cg.getOutgoingEdges(child.id).filter(e => e.kind === 'calls');
expect(edges.map(e => cg.getNode(e.target)!.filePath)).toContain('base.' + ext);
expect(edges.some(e => e.target === child.id)).toBe(false);
expect(edges.map(e => cg.getNode(e.target)!.filePath)).not.toContain('decoy.' + ext);
});
});

it('preserves genuine recursion on the same line as a super call', async () => {
const cg = await project({
['base.' + ext]: 'export class Base { run() {} }',
['child.' + ext]: `import { Base } from './base'; export class Child extends Base { run(n) { super.run(); if (n) this.run(n - 1); } }`,
});
const child = cg.getNodesInFile('child.' + ext).find(n => n.kind === 'method')!;
expect(cg.getOutgoingEdges(child.id)).toContainEqual(expect.objectContaining({ kind: 'calls', target: child.id }));
});

it('leaves an external parent unresolved instead of picking a namesake', async () => {
const cg = await project({
['decoy.' + ext]: 'export class Local { run() {} }',
['child.' + ext]: `import { Base } from 'external-package'; export class Child extends Base { run() { return super.run(); } }`,
});
const child = cg.getNodesInFile('child.' + ext).find(n => n.kind === 'method')!;
expect(cg.getOutgoingEdges(child.id).filter(e => e.kind === 'calls')).toEqual([]);
});
});
17 changes: 17 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { clearDartLibraryMemos } from './dart-libraries';
import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers';
import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters';
import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope';
import { matchJsSuperCall, clearJsConstructorMemos } from './js-constructor-methods';
import { clearCppTypeAliasMemos } from './cpp-type-aliases';
import { clearCppIncluderMemos } from './cpp-includers';
import { matchShopifyThemeFile } from './shopify-themes';
Expand Down Expand Up @@ -572,6 +573,7 @@ export class ReferenceResolver {
clearVbnetReceiverMemos(this.context);
clearTypeParameterMemos(this.context);
clearDartLocalScopeMemos(this.context);
clearJsConstructorMemos(this.context);
clearCppTypeAliasMemos(this.context);
clearCppSupertypeMemos(this.context);
clearCppIncluderMemos(this.context);
Expand Down Expand Up @@ -924,6 +926,16 @@ export class ReferenceResolver {
return supers;
},

getSuperclassNodes: (id: string) => {
const key = `extends\0${id}`;
const hit = this.supertypeNodeMemo.get(key);
if (hit && hit.gen === this.supertypeGen) return hit.nodes;
const nodes = this.queries.getOutgoingEdges(id, ['extends'])
.map(edge => this.nodeById(edge.target)).filter((node): node is Node => !!node);
this.supertypeNodeMemo.set(key, { gen: this.supertypeGen, nodes });
return nodes;
},

getSupertypeNodes: (id: string) => {
const hit = this.supertypeNodeMemo.get(id);
if (hit && hit.gen === this.supertypeGen) return hit.nodes;
Expand Down Expand Up @@ -1261,6 +1273,8 @@ export class ReferenceResolver {
}

private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null {
const jsSuper = matchJsSuperCall(ref, this.context);
if (jsSuper !== undefined) return jsSuper;
// A local C++ object construction (`T obj(args)`, ref `ns::T::T/1`)
// resolves ONLY to a constructor of the lexically nearest `T` (#1839).
if (isCppConstructorRef(ref)) return matchCppConstructor(ref, this.context);
Expand Down Expand Up @@ -3355,6 +3369,9 @@ export class ReferenceResolver {
*/
private gateSuperSelfCall(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
if (!result || ref.referenceKind !== 'calls' || result.targetNodeId !== ref.fromNodeId) return result;
// JS/TS super calls were already handled at their exact call-site above;
// a real recursive call on the same line must retain its self-edge.
if (['javascript', 'jsx', 'typescript', 'tsx', 'arkts'].includes(ref.language)) return result;
const name = ref.referenceName.slice(Math.max(ref.referenceName.lastIndexOf('.'), ref.referenceName.lastIndexOf(':')) + 1);
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return result;
const line = (this.context.getFileLines?.(ref.filePath) ?? this.context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1];
Expand Down
Loading