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

- In TypeScript and JavaScript, a method called on a freshly constructed object, such as `new RegExp(p).exec(s)` or `new URL(u).toString()`, now links only to that class's own (or inherited) method and no longer to an unrelated project method of the same name. (#1945)
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
- In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588)
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
Expand Down
2 changes: 1 addition & 1 deletion __tests__/expression-receiver-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe('expression receivers', () => {
.map((r) => r.referenceName);
expect(refs).toEqual([
'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget',
'f', 'run', 'go', 'start',
'f', 'run', 'new Runner().go', 'start',
]);
});
});
3 changes: 3 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ async function exprReceivers(x) {
(() => 1).call(null);
this.a.b.run();
new Runner().go();
new RegExp(p).exec(s);
(new ns.Widget(1)).draw();
new (pick())().go();
}
// --- component wrappers walked under the component ------------------------------
const PANEL_GAP = 8;
Expand Down
5 changes: 4 additions & 1 deletion __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ async function exprReceivers(x, y) {
this.a.b.run();
super.stop();
new Runner().go();
(new ns.Widget(1)).draw();
new (pick())().go();
new Runner().a.run();
window.Api.start();
}
`, language);
Expand All @@ -304,7 +307,7 @@ async function exprReceivers(x, y) {
.map((r) => r.referenceName)).toEqual([
'list().map', 'list', 'x.run',
...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []),
'f', 'run', 'stop', 'go', 'start',
'f', 'run', 'stop', 'new Runner().go', 'new ns.Widget().draw', 'pick', 'start',
]);
});

Expand Down
106 changes: 106 additions & 0 deletions __tests__/new-expression-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* A method called on a `new C(...)` receiver resolves on `C` or nowhere.
* `new RegExp(p).exec(s)` used to reach the resolver as the bare `exec` and
* exact-match a project database wrapper's `exec`; the extractor now keeps the
* constructed class (`new RegExp().exec`), so a built-in or external class
* binds to nothing while a project class resolves its own (or inherited)
* method.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';

let dir: string;
let cg: CodeGraph;

const files: Record<string, string> = {
'src/db.ts':
'export class SqliteDatabase { exec(sql: string) { return sql; } get(k: string) { return k; } }\n' +
'export class Printer { toString() { return "p"; } }\n',
'src/runner.ts':
'export class Base { baseMethod() { return 1; } }\n' +
'export class Runner { run() { return 1; } }\n' +
'export class Sub extends Base { own() { return 2; } }\n',
'src/use.ts':
"import { Runner, Sub, Runner as R } from './runner';\n" +
"import * as rr from './runner';\n" +
'export function builtins(p: string, u: string, s: string) {\n' +
' const a = new RegExp(p).exec(s);\n' +
' const b = new URL(u).toString();\n' +
' const c = new Map([[1, 2]]).get(1);\n' +
' const d = new Date().getTime();\n' +
' return [a, b, c, d];\n' +
'}\n' +
'export function projectClasses() {\n' +
' return new Runner().run() + new Sub().baseMethod() + new Sub().own() + new R().run() + new rr.Runner().run();\n' +
'}\n',
'src/plain.js':
"const { Runner } = require('./runner');\n" +
'function jsCalls(p, s) {\n' +
' new RegExp(p).exec(s);\n' +
' new Set([1]).get(1);\n' +
' return new Runner().run();\n' +
'}\n' +
'module.exports = { jsCalls };\n',
};

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-newrecv-'));
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});

afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});

const node = (name: string, file: string) =>
cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && (n.kind === 'function' || n.kind === 'method'))!;
const calleesOf = (name: string, file: string) =>
cg.getCallees(node(name, file).id)
.filter(({ edge }) => edge.kind === 'calls')
.map(({ node: n }) => n.qualifiedName)
.sort();

describe('new-expression receivers', () => {
it('TS: a built-in or external constructed class binds to no project method', () => {
expect(calleesOf('builtins', 'use.ts')).toEqual([]);
expect(cg.getCallers(node('exec', 'db.ts').id)).toEqual([]);
expect(cg.getCallers(node('get', 'db.ts').id)).toEqual([]);
expect(cg.getCallers(node('toString', 'db.ts').id)).toEqual([]);
});

it('TS: a project class resolves its own and inherited methods, via alias and namespace too', () => {
expect([...new Set(calleesOf('projectClasses', 'use.ts'))]).toEqual(['Base::baseMethod', 'Runner::run', 'Sub::own']);
expect(cg.getCallers(node('run', 'runner.ts').id).map(({ node: n }) => n.name)).toContain('projectClasses');
});

it('JS: the same rules apply', () => {
expect(calleesOf('jsCalls', 'plain.js')).toEqual(['Runner::run']);
});

it('extraction keeps the constructed class in the ref name', () => {
const src =
'function f(p, s) {\n' +
' new RegExp(p).exec(s);\n' +
' (new ns.Widget<T>(1)).draw();\n' +
' new (pick())().go();\n' +
' new Runner().a.run();\n' +
'}\n';
const refs = extractFromSource('t.ts', src, 'typescript').unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(refs).toEqual(['new RegExp().exec', 'new ns.Widget().draw', 'pick']);
});
});
19 changes: 15 additions & 4 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1665,6 +1665,19 @@ impl<'t> Walker<'t> {
// TreeSitterExtractor.extractCall.
let Some(inner) = self.plain_inner_callee(r) else { return };
callee_name = format!("{inner}().{method_name}");
} else if let Some(r) = receiver.filter(|r| r.kind() == "new_expression") {
// Constructor receiver — `new Runner().run()`: keep the
// class as `new <Class>().<method>` so the resolver
// resolves the method on it or nowhere; emit nothing for
// a constructor that is not a plain name / member chain.
// Mirrors TreeSitterExtractor.extractCall.
let Some(ctor) = r
.child_by_field_name("constructor")
.and_then(|c| Self::plain_member_name(self.text(c)))
else {
return;
};
callee_name = format!("new {ctor}().{method_name}");
} else if receiver.is_some_and(|r| !keeps_bare_receiver(r, self.src)) {
// An expression receiver with no static type
// (`(a ?? b).map()`, `f().list.map()`): emit nothing
Expand Down Expand Up @@ -2083,9 +2096,8 @@ fn peel_receiver(node: Node<'_>) -> Node<'_> {
cur
}

/// `this` / `super`, a member chain rooted at either or at `window`, or
/// `new C()` still collapse to the bare method name (mirrors
/// keepsBareTsJsReceiver).
/// `this` / `super`, or a member chain rooted at either or at `window`, still
/// collapse to the bare method name (mirrors keepsBareTsJsReceiver).
fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
let mut cur = node;
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
Expand All @@ -2097,7 +2109,6 @@ fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
match cur.kind() {
"this" | "super" => true,
"identifier" => &src[cur.byte_range()] == "window",
"new_expression" => cur.id() == node.id(),
_ => false,
}
}
23 changes: 18 additions & 5 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,10 +646,9 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S

/**
* Whether a TS/JS receiver still collapses to the bare method name: `this` /
* `super` (the resolver reads the owner off the enclosing class), a member
* `super` (the resolver reads the owner off the enclosing class) and a member
* chain rooted at either or at `window` (the project-global escape of
* {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the
* call).
* {@link isUnresolvedTsJsChain}). `new C().m()` has its own encoding.
*/
function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean {
let cur: SyntaxNode | null = node;
Expand All @@ -659,8 +658,7 @@ function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean {
}
if (!cur) return false;
if (cur.type === 'this' || cur.type === 'super') return true;
if (cur.type === 'identifier') return getNodeText(cur, source) === 'window';
return cur === node && cur.type === 'new_expression';
return cur.type === 'identifier' && getNodeText(cur, source) === 'window';
}

/**
Expand Down Expand Up @@ -6165,6 +6163,21 @@ export class TreeSitterExtractor {
const chain = getNodeText(func, this.source).replace(/\s+/g, '').replace(/\?\./g, '.');
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){2,}$/.test(chain)) return;
calleeName = chain;
} else if (TS_JS_CHAIN_LANGUAGES.has(this.language) && receiver?.type === 'new_expression') {
// Constructor receiver — `new Runner().run()`, `new RegExp(p).exec(s)`.
// The class is written at the call, so keep it: `new Runner().run`
// tells the resolver the receiver is an INSTANCE of `Runner`, and
// the method resolves on that class (or a supertype) or nowhere —
// a built-in or external class has no project method to bind to.
// The bare method name this used to emit exact-matched any
// same-named project method (`new RegExp(p).exec()` onto a
// database wrapper's `exec`). A constructor that is not a plain
// name or member chain (`new (f())().m()`) emits nothing.
// Mirrored in the kernel's extract_call (tsjs/extractors.rs).
const ctor = getChildByField(receiver, 'constructor');
const ctorName = ctor ? getNodeText(ctor, this.source).replace(/\s+/g, '') : '';
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(ctorName)) return;
calleeName = `new ${ctorName}().${methodName}`;
} else if (
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
receiver &&
Expand Down
16 changes: 14 additions & 2 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
Expand Down Expand Up @@ -1757,7 +1757,17 @@ export class ReferenceResolver {
CHAIN_SHAPE.test(ref.referenceName) &&
(ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
) {
return this.gateLanguage(matchReference(ref, this.context), ref);
const chainResult = this.gateLanguage(matchReference(ref, this.context), ref);
// `new Sub().inherited()`: the method may live on a supertype of a
// project class, resolvable once extends edges exist — defer to the
// conformance pass like the other chained calls.
if (!chainResult && NEW_RECEIVER_SHAPE.test(ref.referenceName)) {
const cls = newReceiverClass(ref, this.context);
if (cls && this.context.getNodesByName(cls).some((n) => n.kind === 'class' && sameLanguageFamily(n.language, ref.language))) {
this.deferReference(ref, this.deferredChainRefs);
}
}
return chainResult;
}

const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
Expand Down Expand Up @@ -2205,6 +2215,8 @@ export class ReferenceResolver {
// dotted-receiver languages on `.` (matchDottedCallChain).
const chainMatch = (ref.language === 'php' && PHP_PROP_SHAPE.test(ref.referenceName))
? matchMethodCall(ref, this.context)
: NEW_RECEIVER_SHAPE.test(ref.referenceName)
? matchNewReceiverCall(ref, this.context)
: SCOPED_CHAIN_LANGUAGES.has(ref.language)
? matchScopedCallChain(ref, this.context)
: matchDottedCallChain(ref, this.context);
Expand Down
61 changes: 61 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25801,6 +25801,64 @@ function matchStoreAccessorChain(ref: UnresolvedRef, context: ResolutionContext)
return { original: ref, targetNodeId: callables[0]!.id, confidence: 0.6, resolvedBy: 'exact-match' };
}

/**
* TS/JS constructor receiver `new Runner(args).run()`, encoded by the
* extractor as `new Runner().run`. The receiver is an instance of the class
* written at the call, so this shape resolves only through that class.
*/
export const NEW_RECEIVER_SHAPE = /^new ([\w$.]+)\(\)\.([\w$]+)$/;

/**
* The class a `new C().m` ref constructs, as the name its declaration carries:
* `ns.Runner` → `Runner`, an aliased named import `R` → `Runner`. A JS/TS
* built-in (`RegExp`, `Map`, `Date`…) counts only when the file imports or
* declares a class of that name — otherwise a project class that merely
* shares the name would take every `new Map().get()` in the repo.
*/
export function newReceiverClass(ref: UnresolvedRef, context: ResolutionContext): string | null {
const m = ref.referenceName.match(NEW_RECEIVER_SHAPE);
if (!m || !m[1]) return null;
const written = m[1];
const last = written.split('.').pop()!;
const imported = written.includes('.')
? undefined
: context.getImportMappings(ref.filePath, ref.language).find((i) => i.localName === written);
if (imported && !imported.isDefault && !imported.isNamespace && /^[A-Za-z_$][\w$]*$/.test(imported.exportedName)) {
return imported.exportedName;
}
if (!imported && !written.includes('.') && JS_BUILT_INS.has(last) &&
!context.getNodesInFile(ref.filePath).some((n) => n.kind === 'class' && n.name === last)) {
return null;
}
return last;
}

/**
* Resolve a {@link NEW_RECEIVER_SHAPE} call on the constructed class or one of
* its supertypes (validated by resolveMethodOnType), or on a function assigned
* onto an ES5 constructor function's prototype in its file, or not at all: a
* class with no project declaration — `new URL(u).toString()` — has no
* project method to bind to.
*/
export function matchNewReceiverCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const cls = newReceiverClass(ref, context);
const method = ref.referenceName.match(NEW_RECEIVER_SHAPE)?.[2];
if (!cls || !method) return null;
const onClass = resolveMethodOnType(cls, method, ref, context, 0.9, 'instance-method');
if (onClass) return onClass;
// An ES5 constructor function (`function Params() {}`) keeps its methods as
// functions assigned onto its prototype in its own file
// (`prototype.render = function render() {…}`).
const ctors = context.getNodesByName(cls).filter((n) => n.kind === 'function' && sameLanguageFamily(n.language, ref.language));
if (ctors.length !== 1) return null;
const lines = context.getFileLines?.(ctors[0]!.filePath) ?? context.readFile(ctors[0]!.filePath)?.split(/\r?\n/);
const onProto = context.getNodesInFile(ctors[0]!.filePath).filter((n) =>
n.name === method && (n.kind === 'function' || n.kind === 'method') && /\bprototype\b/.test(lines?.[n.startLine - 1] ?? ''));
return onProto.length === 1
? { original: ref, targetNodeId: onProto[0]!.id, confidence: 0.8, resolvedBy: 'instance-method' }
: null;
}

/** Resolve the implementation inside the identified store, not a namesake or
* an interface signature elsewhere in the project. Import resolution already
* follows aliases/barrels; containment already excludes nested action locals. */
Expand Down Expand Up @@ -27201,6 +27259,9 @@ function matchReferenceInner(
const chained = nmTimed('pythonFactoryChain', ref, () => matchPythonFactoryChain(ref, context));
if (chained) return chained;
}
if (ref.referenceName.startsWith('new ')) {
return nmTimed('newReceiver', ref, () => matchNewReceiverCall(ref, context));
}
return nmTimed('storeAccessorChain', ref, () => matchStoreAccessorChain(ref, context));
}

Expand Down