diff --git a/CHANGELOG.md b/CHANGELOG.md index b74c5d37ef..c9b69611e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/js-constructor-regressions.test.ts b/__tests__/js-constructor-regressions.test.ts new file mode 100644 index 0000000000..c7751cbf85 --- /dev/null +++ b/__tests__/js-constructor-regressions.test.ts @@ -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) { + 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); +}); diff --git a/__tests__/js-constructor-super.test.ts b/__tests__/js-constructor-super.test.ts new file mode 100644 index 0000000000..b5538a99d9 --- /dev/null +++ b/__tests__/js-constructor-super.test.ts @@ -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): Promise { + 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([]); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index cac05d6ce9..08c9cc8d9a 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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'; @@ -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); @@ -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; @@ -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); @@ -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]; diff --git a/src/resolution/js-constructor-methods.ts b/src/resolution/js-constructor-methods.ts new file mode 100644 index 0000000000..9f460f2a86 --- /dev/null +++ b/src/resolution/js-constructor-methods.ts @@ -0,0 +1,153 @@ +import type { Node } from '../types'; +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; +import { blankStringContents, stripCommentsForRegex } from './strip-comments'; + +const LANGUAGES = new Set(['javascript', 'jsx', 'typescript', 'tsx', 'arkts']); +const sourceCache = new WeakMap>(); +const staticCache = new WeakMap>(); +export function clearJsConstructorMemos(context: ResolutionContext): void { + sourceCache.delete(context); + staticCache.delete(context); +} + +function sourceFor(ref: UnresolvedRef, context: ResolutionContext) { + let files = sourceCache.get(context); + if (!files) sourceCache.set(context, files = new Map()); + let cached = files.get(ref.filePath); + if (!cached) { + const raw = context.getFileLines?.(ref.filePath) ?? (context.readFile(ref.filePath) ?? '').split(/\r?\n/); + const source = raw.join('\n'); + const comments = stripCommentsForRegex(source, 'javascript'); + const starts = [0]; + for (let i = 0; i < source.length; i++) if (source[i] === '\n') starts.push(i + 1); + cached = { raw, comments, starts }; + files.set(ref.filePath, cached); + } + return cached; +} +function lines(ref: UnresolvedRef, context: ResolutionContext, raw = false): string[] { + const cached = sourceFor(ref, context); + if (raw) return cached.raw; + return cached.code ??= blankStringContents(cached.comments).split(/\r?\n/); +} +const escape = (name: string): string => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +function isStaticMethod(node: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (node.language !== 'javascript' && node.language !== 'jsx') return !!node.isStatic; + let nodes = staticCache.get(context); + if (!nodes) staticCache.set(context, nodes = new Map()); + const hit = nodes.get(node.id); + if (hit !== undefined) return hit; + // The JS extractor does not yet populate isStatic (SQLite reads its absent + // flag as false); both backends retain the exact method source position. + // TS nodes use their existing flag. + const source = sourceFor({ ...ref, filePath: node.filePath }, context); + const at = (source.starts[node.startLine - 1] ?? 0) + node.startColumn; + const pattern = /static\s+/y; + pattern.lastIndex = at; + const value = !!pattern.exec(source.comments); + nodes.set(node.id, value); + return value; +} + +/** A method on one particular class, never the union of all its namesakes. */ +export function methodOnJsClass(cls: Node, method: string, ref: UnresolvedRef, context: ResolutionContext, + seen = new Set(), isStatic = false): ResolvedRef | null { + if (seen.has(cls.id) || seen.size >= 16) return null; + seen.add(cls.id); + const members = context.getMethodMatches?.(cls.name, method, ref.language) ?? context.getNodesByName(method); + const own = members.filter(n => n.kind === 'method' && n.filePath === cls.filePath && + n.qualifiedName === `${cls.qualifiedName}::${method}` && n.startLine >= cls.startLine && n.endLine <= cls.endLine && + isStaticMethod(n, ref, context) === isStatic); + if (own.length === 1) return { original: ref, targetNodeId: own[0]!.id, confidence: 0.95, resolvedBy: 'instance-method' }; + if (own.length > 1) return null; + for (const parent of context.getSuperclassNodes?.(cls.id) ?? []) { + if (parent.kind !== 'class') continue; + const match = methodOnJsClass(parent, method, ref, context, seen, isStatic); + if (match) return match; + } + return null; +} + +/** undefined: no imported constructor binding; null: an import we cannot prove. */ +export function matchImportedConstructorMethod(type: string, receiver: string, method: string, + ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (!LANGUAGES.has(ref.language) || !/^[\w$]+$/.test(receiver)) return undefined; + if (!context.resolveImport || !context.getNodeById) return undefined; + const binding = context.getImportMappings?.(ref.filePath, ref.language)?.find(i => i.localName === type && !i.isNamespace); + if (!binding) return undefined; + const imported = context.resolveImport({ ...ref, referenceName: type, referenceKind: 'instantiates' }); + const cls = imported && context.getNodeById(imported.targetNodeId); + // Ordinary unaliased receivers already have the right class identity. Do + // not replace their existing inference for returned/destructured values. + // Keep recovery for aliases/re-exports and genuinely ambiguous namesakes. + if (binding.localName === binding.exportedName && !binding.isDefault && cls?.kind === 'class' && cls.name === type) { + const matches = context.getNodesByName(type).filter(n => n.kind === 'class' && LANGUAGES.has(n.language)); + if (matches.length === 1 && matches[0]!.id === cls.id) return undefined; + } + const scopes = context.getNodesInFile(ref.filePath).filter(n => + (n.kind === 'function' || n.kind === 'method') && n.startLine <= ref.line && n.endLine >= ref.line); + const code = lines(ref, context); + const begin = scopes.reduce((line, n) => Math.max(line, n.startLine), 1); + const prefix = code.slice(begin - 1, ref.line).join('\n'); + // The blanker intentionally hides executable template substitutions too. + // Leave that unsupported binding shape to the existing resolver, rather + // than miss a reassignment inside it while adding an alias-derived edge. + if (lines(ref, context, true).slice(begin - 1, ref.line).join('\n').includes('${')) return undefined; + const t = escape(type), r = escape(receiver); + if (!new RegExp(`\\b(?:const|let|var)\\s+${r}\\s*=\\s*new\\s+${t}\\s*\\(`).test(prefix)) return undefined; + // Do not recover an import through a shadowing parameter/local, or recover + // the original instance after reassignment. These guards are conservative: + // unsupported binding forms stay unresolved instead of choosing a namesake. + if (scopes.some(n => new RegExp(`(?:[(,]|\\.\\.\\.)\\s*${t}\\s*(?:[:=,)]|$)`).test(n.signature ?? ''))) return null; + const scopeCode = code.slice(begin - 1, Math.max(ref.line, ...scopes.map(n => n.endLine))).join('\n'); + if (new RegExp(`\\b(?:const|let|var|class|function)\\s+${t}(?![\\w$])`).test(scopeCode)) { + const localClass = context.getNodesInFile(ref.filePath).filter(n => n.kind === 'class' && n.name === type && + n.startLine >= begin && n.startLine <= ref.line).sort((a, b) => b.startLine - a.startLine)[0]; + // A known local class belongs to ordinary local receiver resolution; the + // import recovery must not introduce a new target through a shadow name. + return localClass ? undefined : null; + } + // A recovery is deliberately narrower than JavaScript binding analysis: + // destructured/catch/arrow parameters and other uses of the alias cannot + // prove that a `new` still names the import. Existing unaliased resolution + // remains authoritative for sibling blocks with separate declarations. + const scopeWithoutImports = scopeCode.replace(/\bimport\b[^;\n]*(?:;|$)/gm, ''); + if (binding.localName !== binding.exportedName || binding.isDefault) { + for (const use of scopeWithoutImports.matchAll(new RegExp(`(?)|[+*/%&|^\\-]=|\\*\\*=|&&=|\\|\\|=|\\?\\?=|\\+\\+|--)`, 'g'))].length !== 1) { + return binding.localName === binding.exportedName && !binding.isDefault ? undefined : null; + } + if (new RegExp(`(?:\\[[^\\]\\n]*\\b${r}\\b[^\\]\\n]*\\]|\\{[^}\\n]*\\b${r}\\b[^}\\n]*\\})\\s*=`).test(prefix)) return null; + return cls?.kind === 'class' ? methodOnJsClass(cls, method, ref, context) : null; +} + +/** Match explicit super calls by their call-site position and resolved parent. */ +export function matchJsSuperCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (!LANGUAGES.has(ref.language) || ref.referenceKind !== 'calls') return undefined; + const code = sourceCache.get(context)?.get(ref.filePath)?.raw ?? context.getFileLines?.(ref.filePath) ?? lines(ref, context, true); + const head = code[ref.line - 1]?.slice(ref.column) ?? ''; + if (!/^super\b/.test(head)) return undefined; + const cached = sourceFor(ref, context); + // Sticky matching reads the call at its exact offset in the one cached + // comment-stripped source, without copying/re-stripping the file suffix. + const pattern = /super\s*\.\s*([\w$]+)\s*\(/y; + pattern.lastIndex = (cached.starts[ref.line - 1] ?? 0) + ref.column; + const call = pattern.exec(cached.comments); + if (!call) return null; // an explicit but unsupported super expression is never a name guess + const cls = context.getNodesInFile(ref.filePath).filter(n => n.kind === 'class' && + (n.startLine < ref.line || (n.startLine === ref.line && n.startColumn <= ref.column)) && + (n.endLine > ref.line || (n.endLine === ref.line && n.endColumn >= ref.column))) + .sort((a, b) => b.startLine - a.startLine || b.startColumn - a.startColumn)[0]; + if (!cls) return null; + const caller = context.getNodeById?.(ref.fromNodeId); + for (const parent of context.getSuperclassNodes?.(cls.id) ?? []) { + if (parent.kind !== 'class') continue; + const match = methodOnJsClass(parent, call[1]!, ref, context, new Set(), caller ? isStaticMethod(caller, ref, context) : false); + if (match) return match; + } + return null; +} diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6df55a82ef..805ba61552 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9,6 +9,7 @@ import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; +import { matchImportedConstructorMethod } from './js-constructor-methods'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, inSameDartLibrary } from './dart-libraries'; @@ -11933,6 +11934,8 @@ export function matchMethodCall( inferredType = awaited.name; } if (inferredType) { + const importedConstructor = matchImportedConstructorMethod(inferredType, objectOrClass!, methodName!, ref, context); + if (importedConstructor !== undefined) return importedConstructor; // A Go local bound from a type assertion calls a method of the type Go // finds for it, or nothing the project declares: `flusher.Flush()` after // `flusher, ok := w.(http.Flusher)` went to a project type named after it. diff --git a/src/resolution/types.ts b/src/resolution/types.ts index d61c9a40b3..ee9770e80a 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -164,6 +164,8 @@ export interface ResolutionContext { * Optional so external/test contexts compile without it. */ getSupertypeNodes?(id: string): Node[]; + /** Resolved extends edges only; implements is not a runtime superclass. */ + getSuperclassNodes?(id: string): Node[]; /** * Look up a node by its id. Lets matchers derive the FROM-symbol's * enclosing-class scope (Swift implicit-self method scoping, `this.X`