From 14a46653f65b08a961b9d17857e6ecc6b4a0ea2f Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 19:09:25 +0300 Subject: [PATCH 1/2] fix(resolution): resolve TS/JS calls on a `new C()` receiver through C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new RegExp(p).exec(s)`, `new Array(n).fill(x)`, `new TextDecoder().decode(b)` reached the resolver as the bare method name and exact-matched any project method of that name (in codegraph's own index `new RegExp().exec` onto `SqliteDatabase::exec`, `new Array().fill` onto `TextOptions::fill`). Extraction (mirrored in the kernel) now keeps the constructed class: `new C(args).m()` is encoded `new C().m` (a member-chain constructor keeps its path, `new ns.C().m`); a constructor that is not a plain name or member chain emits nothing. The resolver resolves that shape only on C — through a named import alias to the declared name, and on a supertype in the conformance pass when C is a project class — so a built-in or external class yields no edge. A JS built-in name counts as a project class only when the file imports or declares it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/expression-receiver-calls.test.ts | 2 +- __tests__/fixtures/kernel-parity/torture.js | 3 + __tests__/kernel-tsjs-parity.test.ts | 5 +- __tests__/new-expression-receiver.test.ts | 106 ++++++++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 19 +++- src/extraction/tree-sitter.ts | 23 ++++- src/resolution/index.ts | 16 ++- src/resolution/name-matcher.ts | 48 +++++++++ 9 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 __tests__/new-expression-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb9e7357..983121c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's ` items={…} />` or excalidraw's ` …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading. - In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. - In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading. +- 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) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/expression-receiver-calls.test.ts b/__tests__/expression-receiver-calls.test.ts index e22de1f74..1caa470b5 100644 --- a/__tests__/expression-receiver-calls.test.ts +++ b/__tests__/expression-receiver-calls.test.ts @@ -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', ]); }); }); diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 6221c7a77..8db2b9399 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -101,4 +101,7 @@ 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(); } diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 38e467285..b51dfa9dd 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -278,6 +278,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); @@ -286,7 +289,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', ]); }); diff --git a/__tests__/new-expression-receiver.test.ts b/__tests__/new-expression-receiver.test.ts new file mode 100644 index 000000000..5a0d62d6f --- /dev/null +++ b/__tests__/new-expression-receiver.test.ts @@ -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 = { + '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(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']); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 1c84f8c33..fec8dfba7 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1453,6 +1453,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 ().` 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 @@ -1791,9 +1804,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") { @@ -1805,7 +1817,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, } } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index eebcab5f0..90ce612f8 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -500,10 +500,9 @@ function peelTsJsReceiver(node: SyntaxNode): SyntaxNode { /** * 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; @@ -513,8 +512,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'; } /** @@ -5609,6 +5607,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 && diff --git a/src/resolution/index.ts b/src/resolution/index.ts index cac05d6ce..a0096fc25 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, 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'; @@ -1479,7 +1479,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; @@ -1906,6 +1916,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); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2726405ff..3bf37f183 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -15006,6 +15006,51 @@ 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 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; + return resolveMethodOnType(cls, method, ref, context, 0.9, 'instance-method'); +} + /** 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. */ @@ -16332,6 +16377,9 @@ function matchReferenceInner( ref.referenceName.includes('().') && (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python') ) { + if (ref.referenceName.startsWith('new ')) { + return nmTimed('newReceiver', ref, () => matchNewReceiverCall(ref, context)); + } return nmTimed('storeAccessorChain', ref, () => matchStoreAccessorChain(ref, context)); } From af4ae3b35cfc4e1d3cfb21f8a082f8e74364349b Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Wed, 30 Sep 2026 21:28:47 +0300 Subject: [PATCH 2/2] fix(resolution): a `new C().m()` on an ES5 constructor reaches its prototype method With the constructed class kept in the ref, `new Params().render()` resolved only on a class `Params`; an ES5 constructor function has none, so the call lost the edge main's #2162 keeps to `prototype.render = function render()` in the constructor's file. When no class method matches and the constructed name is one project function, a same-named function assigned onto a prototype in that function's file is the target. Co-Authored-By: Claude Opus 5.5 --- src/resolution/name-matcher.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 3bf37f183..caa86ffdf 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -15040,15 +15040,28 @@ export function newReceiverClass(ref: UnresolvedRef, context: ResolutionContext) /** * Resolve a {@link NEW_RECEIVER_SHAPE} call on the constructed class or one of - * its supertypes (validated by resolveMethodOnType), or not at all: a class - * with no project declaration — `new URL(u).toString()` — has no project - * method to bind to. + * 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; - return resolveMethodOnType(cls, method, ref, context, 0.9, 'instance-method'); + 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