diff --git a/CHANGELOG.md b/CHANGELOG.md index 337175c1c..acec6014e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- JavaScript and TypeScript calls through CommonJS or default imports of modules without a value default no longer link missing members to unrelated class methods; re-index after upgrading. - TypeScript generic calls and typed tags using `typeof abstract` now retain their callers, while runtime `typeof` comparisons and optional chains keep their runtime meaning; re-index TypeScript projects after upgrading. - TypeScript classes with members named `abstract` or `accessor`, and auto-accessors with `static`, `override` or `abstract` modifiers, now index correctly; re-index TypeScript projects after upgrading. - A TSX file with a broken JSX attribute such as `c:d(e f)` no longer stalls indexing for about 90 seconds and then drops out of the index; it is indexed like any other file with a syntax error. diff --git a/__tests__/commonjs-export-members.test.ts b/__tests__/commonjs-export-members.test.ts new file mode 100644 index 000000000..9b423f0f0 --- /dev/null +++ b/__tests__/commonjs-export-members.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { readCommonJsExportMembers } from '../src/resolution/commonjs-export-members'; + +describe('CommonJS export member discovery', () => { + it.each([ + 'exports.load = Store.prototype.load;', + 'module.exports.load = Store.prototype.load;', + "exports['load'] = Store.prototype.load;", + 'module.exports["load"] = Store.prototype.load;', + 'module["exports"]["load"] = Store.prototype.load;', + 'exports /* target */ . /* key */ load = Store.prototype.load;', + 'const text = `${exports.load = Store.prototype.load}`;', + 'const text = `outer ${`inner ${exports.load = Store.prototype.load}`}`;', + 'function generic() { exports.load = Store.prototype.load; }', + ])('finds executable assignment: %s', (source) => { + const result = readCommonJsExportMembers(source); + expect([...result.members]).toEqual(['load']); + expect(result.uncertain).toBe(false); + }); + + it.each([ + '// exports.missing = fake;', + '/* exports.missing = fake; */', + 'const text = "exports.missing = fake;";', + 'const text = `exports.missing = fake;`;', + 'const pattern = /exports.missing = fake/;', + 'if (true) /exports.missing = fake/.test("x");', + 'function setup(holder) { holder.exports.missing = 42; }', + 'function setup(holder) { holder . exports . missing = 42; }', + 'function setup(holder) { holder.module.exports.missing = 42; }', + 'holder?.exports.missing;', + '$exports.missing = fake;', + 'αexports.missing = fake;', + 'exports.missing === fake;', + 'const text = "module exports";', + 'let value: typeof exports.missing = undefined;', + 'type Loader = typeof require.cache;', + 'class Evaluator { eval(code) {} }', + ])('ignores non-export text: %s', (source) => { + const result = readCommonJsExportMembers(source); + expect([...result.members]).toEqual([]); + expect(result.uncertain).toBe(false); + }); + + it.each([ + 'module.exports = new Store();', + 'take(exports);', + 'Object.setPrototypeOf(exports, Store.prototype);', + 'module.exports.__proto__ = Store.prototype;', + 'exports[key] = Store.prototype.load;', + 'exports["lo\\u0061d"] = Store.prototype.load;', + "eval('exports.load = Store.prototype.load;');", + 'eval(`exports.load = Store.prototype.load;`);', + 'eval(`exports.load = ${Store.prototype.load};`);', + "(eval)('exports.load = Store.prototype.load;');", + "((eval))(('exports.load = Store.prototype.load;'));", + 'require.cache[__filename].exports = new Store();', + "const Module = require('node:module');", + "import { Module } from 'node:module';", + "const Module = await import('node:module');", + ])('preserves uncertain object APIs: %s', (source) => { + expect(readCommonJsExportMembers(source).uncertain).toBe(true); + }); + + it('ignores JSX text but keeps assignments in JSX expressions', () => { + const source = '
exports.missing = fake{exports.load = Store.prototype.load}
'; + const result = readCommonJsExportMembers(source, true); + expect([...result.members]).toEqual(['load']); + expect(result.uncertain).toBe(false); + }); +}); diff --git a/__tests__/js-default-binding-members.test.ts b/__tests__/js-default-binding-members.test.ts index cc76ffcee..062862236 100644 --- a/__tests__/js-default-binding-members.test.ts +++ b/__tests__/js-default-binding-members.test.ts @@ -106,6 +106,92 @@ describe('a member read through a default binding', () => { expect(callsOf(cg, 'run')).toEqual([]); }); + it.each([ + ["const named = require('./m');", 'Decoy'], + ["import named from './m.js';", 'Decoy'], + ["const named = require('./m');", 'named'], + ])('does not borrow an unimported method after %s, even from class %s', async (binding, className) => { + const cg = await indexProject({ + 'm.js': 'exports.known = function known() {};\n', + 'decoy.js': `export class ${className} { static missing() {} }\n`, + 'use.js': CALLER(binding, ['missing']), + }); + expect(callsOf(cg, 'run')).toEqual([]); + }); + + it('keeps real exports beside an unimported method and through a forwarding module', async () => { + const cg = await indexProject({ + 'm.js': 'exports.second = function second() {};\n', + 'index.js': "module.exports = require('./m');\n", + 'decoy.js': 'export class Decoy { second() {} missing() {} }\n', + 'use.js': CALLER("const named = require('./index');", ['second', 'missing']), + }); + expect(callsOf(cg, 'run')).toEqual(['3 m.js:second']); + }); + + it('keeps CommonJS stated class, object, instance and callable exports', async () => { + const cg = await indexProject({ + 'class.js': 'class Widget { static make() {} }\nmodule.exports = Widget;\n', + 'object.js': 'const api = { read() {} };\nmodule.exports = api;\n', + 'instance.js': 'class Store { load() {} }\nmodule.exports = new Store();\n', + 'callable.js': 'function create() {}\nmodule.exports = create;\nmodule.exports.extra = function extra() {};\n', + 'decoy.js': 'export class Decoy { make() {} read() {} load() {} extra() {} }\n', + 'use.js': "const Widget = require('./class');\nconst object = require('./object');\nconst store = require('./instance');\nconst create = require('./callable');\nfunction run() {\n Widget.make();\n object.read();\n store.load();\n create();\n create.extra();\n}\nmodule.exports = run;\n", + }); + expect(callsOf(cg, 'run')).toEqual([ + '10 callable.js:extra', '6 class.js:Widget::make', '7 object.js:api::read', + '8 instance.js:Store::load', '9 callable.js:create', + ]); + }); + + it('keeps typed parameters and local objects that shadow a default binding', async () => { + const cg = await indexProject({ + 'm.ts': 'export function known() {}\n', + 'use.ts': "import named from './m';\nclass Local { missing() {} }\nexport function typed(named: Local) {\n named.missing();\n}\nexport function literal() {\n const named = { present() {} };\n named.present();\n}\n", + 'decoy.ts': 'export class Decoy { missing() {} present() {} }\n', + }); + expect(callsOf(cg, 'typed')).toEqual(['4 use.ts:Local::missing']); + expect(callsOf(cg, 'literal')).toEqual(['8 use.ts:literal::named::present']); + }); + + it.each([ + "import named from './m';", + "import * as named from './m';", + ])('keeps a lexical class shadow after %s', async (binding) => { + const cg = await indexProject({ + 'm.ts': 'export function known() {}\n', + 'use.ts': `${binding}\nexport function outer() {\n class named { static missing() {} }\n function run() { named.missing(); }\n return run;\n}\n`, + }); + expect(callsOf(cg, 'run')).toEqual(['4 use.ts:outer::named::missing']); + }); + + it.each([ + 'module.exports = new Store();', + 'export default new Store();', + ])('keeps the instance stated by %s', async (statement) => { + const cg = await indexProject({ + 'm.js': `class Store { load() {} }\n${statement}\n`, + 'decoy.js': 'export class Decoy { missing() {} }\n', + 'use.js': CALLER("const named = require('./m');", ['load']), + }); + expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']); + }); + + it('keeps a missing member unresolved when a decoy is added by sync', async () => { + const cg = await indexProject({ + 'm.js': 'exports.known = function known() {};\n', + 'use.js': CALLER("const named = require('./m');", ['missing']), + }); + expect(callsOf(cg, 'run')).toEqual([]); + const decoy = path.join(roots[roots.length - 1]!, 'decoy.js'); + fs.writeFileSync(decoy, 'export class Decoy { missing() {} }\n'); + await cg.sync(); + expect(callsOf(cg, 'run')).toEqual([]); + fs.unlinkSync(decoy); + await cg.sync(); + expect(callsOf(cg, 'run')).toEqual([]); + }); + it('takes what `module.exports =` states over a neighbour assigned to it', async () => { const cg = await indexProject({ 'm.js': 'function main() {}\nmodule.exports = main;\nmodule.exports.extra = function () {};\n', @@ -114,6 +200,104 @@ describe('a member read through a default binding', () => { expect(callsOf(cg, 'run')).toEqual(['3 m.js:main', '4 m.js:extra']); }); + it.each([ + ['./m', 'named'], ['./index', 'named'], + ['./m', 'Store'], ['./index', 'Store'], + ])('rechecks calls through %s as %s when it changes between a value and a namespace', async (specifier, alias) => { + const value = 'class Store { load() {} }\nmodule.exports = new Store();\n'; + const namespace = 'class Store { load() {} }\nexports.known = function known() {};\n'; + const files = { + 'm.js': value, + 'index.js': "module.exports = require('./m');\n", + 'use.js': CALLER(`const named = require('${specifier}');`, ['load']).replace(/\bnamed\b/g, alias), + }; + const cg = await indexProject(files); + const moduleFile = path.join(roots[roots.length - 1]!, 'm.js'); + expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']); + for (const source of [namespace, value, namespace]) { + fs.writeFileSync(moduleFile, source); + await cg.sync(); + const fresh = await indexProject({ ...files, 'm.js': source }); + expect(callsOf(cg, 'run')).toEqual(callsOf(fresh, 'run')); + expect(callsOf(cg, 'run')).toEqual(source === value ? ['3 m.js:Store::load'] : []); + } + }); + + it.each(['exports.load', 'module.exports.load', "exports['load']", "module.exports['load']"])( + 'keeps the callable alias in %s without borrowing an absent member', async (assignment) => { + const cg = await indexProject({ + 'm.js': `class Store { load() {} }\n${assignment} = Store.prototype.load;\n`, + 'decoy.js': 'export class Decoy { missing() {} }\n', + 'use.js': CALLER("const named = require('./m');", ['load', 'missing']), + }); + expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']); + } + ); + + it('drops an exported callable alias when sync removes only the assignment', async () => { + const cg = await indexProject({ + 'm.js': 'class Store { load() {} }\nexports.load = Store.prototype.load;\n', + 'use.js': CALLER("const named = require('./m');", ['load']), + }); + const moduleFile = path.join(roots[roots.length - 1]!, 'm.js'); + expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']); + fs.writeFileSync(moduleFile, 'class Store { load() {} }\nexports.known = function known() {};\n'); + await cg.sync(); + expect(callsOf(cg, 'run')).toEqual([]); + }); + + it.each([ + "const named = require('./m');", + "import named from './m';", + "import * as named from './m';", + ])('sees a block-local class only inside its block after %s', async (binding) => { + const cg = await indexProject({ + 'm.ts': 'export function known() {}\n', + 'use.ts': `${binding}\nexport function run() {\n if (true) {\n class named { static missing() {} }\n function inside() { named.missing(); }\n }\n named.missing();\n}\n`, + }); + expect(callsOf(cg, 'run')).toEqual([]); + expect(callsOf(cg, 'inside')).toEqual(['5 use.ts:run::named::missing']); + }); + + it('does not mistake a class inside a TypeScript namespace for an import shadow', async () => { + const cg = await indexProject({ + 'm.ts': 'export function known() {}\n', + 'use.ts': "import named from './m';\nnamespace Parent {\n export class named { static missing() {} }\n}\nexport function run() { named.missing(); }\n", + }); + expect(callsOf(cg, 'run')).toEqual([]); + }); + + it.each([ + '// exports.missing = fake;', + '/* exports.missing = fake; */', + 'const text = "exports.missing = fake;";', + 'const text = `exports.missing = fake;`;', + 'const pattern = /exports.missing = fake/;', + 'function setup(holder) { holder.exports.missing = 42; }', + 'function setup(holder) { holder . exports . missing = 42; }', + 'function setup(holder) { holder.module.exports.missing = 42; }', + ])('does not treat non-export text as a member: %s', async (extra) => { + const cg = await indexProject({ + 'm.js': `exports.known = function known() {};\n${extra}\n`, + 'decoy.js': 'export class Decoy { missing() {} }\n', + 'use.js': CALLER("const named = require('./m');", ['missing']), + }); + expect(callsOf(cg, 'run')).toEqual([]); + }); + + it.each([ + "eval('exports.load = Store.prototype.load;');", + 'eval(`exports.load = Store.prototype.load;`);', + "(eval)('exports.load = Store.prototype.load;');", + 'require.cache[__filename].exports = new Store();', + ])('keeps uncertain runtime export APIs: %s', async (mutation) => { + const cg = await indexProject({ + 'm.js': `class Store { load() {} }\n${mutation}\n`, + 'use.js': CALLER("const named = require('./m');", ['load']), + }); + expect(callsOf(cg, 'run')).toEqual(['3 m.js:Store::load']); + }); + it('links the member a sync brings, and drops the one it takes away', async () => { const cg = await indexProject({ 'm.js': 'exports.first = function () {};\n', diff --git a/package-lock.json b/package-lock.json index 524310036..7851f8ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "ignore": "^7.0.14", + "js-tokens": "9.0.1", "jsonc-parser": "^3.3.1", "picomatch": "^4.0.3", "sisteransi": "^1.0.5", @@ -2181,6 +2182,12 @@ "@types/estree": "^1.0.6" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", diff --git a/package.json b/package.json index 9930f901a..23c67dcf7 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "ignore": "^7.0.14", + "js-tokens": "9.0.1", "jsonc-parser": "^3.3.1", "picomatch": "^4.0.3", "sisteransi": "^1.0.5", diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 86debf155..08d0c3b15 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -3653,16 +3653,15 @@ export class ExtractionOrchestrator { /** * Re-open resolution edges whose answer was READ from a file this sync - * touched, although neither end of the edge changed: a python attribute typed - * through a factory or a base class in another file records those files as - * `metadata.typeFrom`. The definition delta cannot see these — editing what a - * factory returns, or what a base `__init__` assigns, adds or removes no - * definition — so without this a synced index keeps the old type forever. + * touched, although neither end of the edge changed: a Python attribute type + * or a JS/TS default import's namespace ownership records those files as + * `metadata.typeFrom`. The definition delta cannot see a factory body or an + * export assignment changing, so a synced index would keep the old answer. * The edges go back to pending refs; returns the files they come from, whose * pending rows the caller resolves. */ resurrectTypeDependentEdges(touchedPaths: string[], changedFilePaths: string[]): string[] { - const touched = new Set(touchedPaths.filter((p) => /\.pyi?$/.test(p))); + const touched = new Set(touchedPaths.filter((p) => /\.(?:pyi?|[cm]?[jt]sx?)$/.test(p))); if (touched.size === 0) return []; const alreadyFresh = new Set(changedFilePaths); diff --git a/src/index.ts b/src/index.ts index 1404a6a33..263314980 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1767,22 +1767,22 @@ export class CodeGraph { result.pendingRefsUnresolved = recovery.stats.unresolved; } - // Python attribute types read from OTHER files — a factory's return, a - // base class's `__init__` — change with a body-only edit the definition - // delta above cannot see. Re-open the edges whose answer was read from a - // touched file, and retry the failed attribute calls in files that - // import one; both resolve right here. After the orphan sweep, so the + // Python attribute types and JS/TS imported-receiver ownership read + // other files whose body/export changes the definition delta cannot + // see. Re-open those edges and retry failed Python attribute calls in + // files that import one; both resolve here. After the orphan sweep, so the // import edges this sync created (a caller whose `from f import make` // only now resolves) are there to find the importers by. if (process.env.CODEGRAPH_NO_REBIND !== '1') { const touched = [...(result.changedFilePaths ?? []), ...(result.removedFilePaths ?? [])]; const touchedPy = touched.filter((p) => /\.pyi?$/.test(p)); - if (touchedPy.length > 0) { + const touchedTypes = touched.filter((p) => /\.(?:pyi?|[cm]?[jt]sx?)$/.test(p)); + if (touchedTypes.length > 0) { const tTypes = Date.now(); - const reopenedFiles = this.orchestrator.resurrectTypeDependentEdges(touchedPy, result.changedFilePaths ?? []); + const reopenedFiles = this.orchestrator.resurrectTypeDependentEdges(touchedTypes, result.changedFilePaths ?? []); // CODEGRAPH_PY_ATTR_RETRY_CEILING overrides the candidate ceiling (tests). const ceiling = Number(process.env.CODEGRAPH_PY_ATTR_RETRY_CEILING ?? '') || 2000; - const failed = this.resolver.pythonAttrRetryCandidates(touchedPy, ceiling); + const failed = touchedPy.length ? this.resolver.pythonAttrRetryCandidates(touchedPy, ceiling) : []; const retry = [...this.queries.getUnresolvedReferencesByFiles(reopenedFiles), ...(failed ?? [])]; if (retry.length > 0) await this.resolver.resolveAndPersistListYielding(retry); if (process.env.CODEGRAPH_SYNTH_TIMINGS) { diff --git a/src/resolution/commonjs-export-members.ts b/src/resolution/commonjs-export-members.ts new file mode 100644 index 000000000..5b1fdf890 --- /dev/null +++ b/src/resolution/commonjs-export-members.ts @@ -0,0 +1,78 @@ +import jsTokens from 'js-tokens'; + +interface LexicalToken { type: string; value: string; closed?: boolean } +interface Property { name: string | null; next: number } + +const TRIVIA = new Set(['WhiteSpace', 'LineTerminatorSequence', 'SingleLineComment', 'MultiLineComment', 'HashbangComment']); + +/** Syntactic CommonJS writes, without interpreting their values or executing code. */ +export function readCommonJsExportMembers(source: string, jsx = false): { members: ReadonlySet; uncertain: boolean } { + const tokens: LexicalToken[] = [...jsTokens(source, { jsx })].filter((t) => !TRIVIA.has(t.type)); + const members = new Set(); + let uncertain = false; + const moduleApi = (token: LexicalToken | undefined): boolean => token?.type === 'StringLiteral' && + (token.value.slice(1, -1) === 'module' || token.value.slice(1, -1) === 'node:module'); + + const literalEvalCall = (at: number): boolean => { + let before = at - 1; + let after = at + 1; + // Parentheses around the bare callee preserve direct eval semantics. + while (tokens[before]?.value === '(' && tokens[after]?.value === ')') { + before--; + after++; + } + if (tokens[after]?.value !== '(') return false; + let argument = after + 1; + while (tokens[argument]?.value === '(') argument++; + const type = tokens[argument]?.type; + return type === 'StringLiteral' || type === 'NoSubstitutionTemplate' || type === 'TemplateHead'; + }; + + const property = (at: number): Property | null => { + const key = tokens[at + 1]; + if (tokens[at]?.value === '.' && key?.type === 'IdentifierName') { + return { name: key.value.includes('\\') ? null : key.value, next: at + 2 }; + } + if (tokens[at]?.value !== '[') return null; + if (key?.type === 'StringLiteral' && key.closed && tokens[at + 2]?.value === ']') { + const name = key.value.slice(1, -1); + return { name: name.includes('\\') ? null : name, next: at + 3 }; + } + return { name: null, next: at + 1 }; + }; + + for (let at = 0; at < tokens.length; at++) { + const root = tokens[at]; + if (root?.type !== 'IdentifierName') continue; + // obj.exports and obj.module.exports are not the CommonJS bindings. + if (tokens[at - 1]?.value === '.' || tokens[at - 1]?.value === '?.') continue; + if (tokens[at - 1]?.value === 'typeof') continue; + // Literal eval code and Node's module/cache APIs can supply exports + // without an assignment to the ordinary CommonJS bindings in this file. + if ((root.value === 'eval' && literalEvalCall(at)) || + (root.value === 'require' && (property(at + 1)?.name === 'cache' || + (tokens[at + 1]?.value === '(' && moduleApi(tokens[at + 2])))) || + (root.value === 'from' && moduleApi(tokens[at + 1])) || + (root.value === 'import' && (moduleApi(tokens[at + 1]) || + (tokens[at + 1]?.value === '(' && moduleApi(tokens[at + 2]))))) { + uncertain = true; + continue; + } + if (root.value !== 'exports' && root.value !== 'module') continue; + let next = at + 1; + if (root.value === 'module') { + const exported = property(next); + if (exported?.name !== 'exports') { uncertain = true; continue; } + next = exported.next; + } + const member = property(next); + // Escaping the object, replacing its default, or mutating its prototype + // supplies an API whose absent members cannot be established here. + if (!member || member.name === null || member.name === '__proto__') { + uncertain = true; + continue; + } + if (tokens[member.next]?.value === '=') members.add(member.name); + } + return { members, uncertain }; +} diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2c758d49f..7e2a68990 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -10,6 +10,7 @@ import { Language, Node, IMPLICIT_RECORD_ACCESSOR_DOC } from '../types'; import { escapeRegExp, escapesRoot } from '../utils'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; +import { readCommonJsExportMembers } from './commonjs-export-members'; import { isJsSuperCallSite } from './js-super-calls'; import { JS_ARRAY_METHODS, JS_BROWSER_HOST_GLOBALS, JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; @@ -22466,6 +22467,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CSHARP_TYPE_ARITY.delete(context); CSHARP_EXTENSION_METHODS.delete(context); JS_ASSIGNED_MEMBERS.delete(context); + JS_MODULE_NAMESPACES.delete(context); CFML_CHAINS.delete(context); NO_RECEIVER_LINES.delete(context); OBJC_SUPERS.delete(context); @@ -24937,6 +24939,14 @@ export function matchMethodCall( if (bounded !== undefined) return bounded; } + // Import, typed-receiver and object-member resolution have had their chance. + // A namespace binding, including a default/require of a module with no + // value default, must not borrow a member from an unrelated class. + // Stop before class-name matching too: the class may share the import alias. + if (ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language) && isImportedModuleReceiver(objectOrClass!, methodName!, ref, context)) { + return null; + } + // Strategy 1: Direct class name match (existing logic). When the receiver // names a class that exists in several files (`Logger.log()` / `Logger::log()` // with a `Logger` in both `a/` and `b/`), try the class in the call site's @@ -25026,16 +25036,6 @@ export function matchMethodCall( objectOrClass !== 'this' && objectOrClass !== 'super' && JS_BUILTIN_METHODS.has(methodName!)) return null; - // A receiver the file imports is past guessing. A namespace import is the - // module object, whose members are its exports, never some class's method; - // a binding from a package outside the repository names nothing in it. The - // import resolver placed what it could, and a method picked by name alone is - // wrong: zod's `z.string()` (`import * as z from "zod/v4"`) bound 3,207 calls - // to a test helper's `string` getter, trpc's `z.record()` another class's. - if (ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language) && isImportedModuleReceiver(objectOrClass!, ref, context)) { - return null; - } - // A receiver written as a type name the project doesn't declare // (`Exception.Create(…)` in Delphi, `Collections.sort(…)`) is a type from // outside it: a same-named method of some project type is a guess. horse's @@ -25382,13 +25382,71 @@ export function matchMethodCall( /** * Is a member call's receiver (`z` in `z.string`, `ns` in `ns.util.fn`) an - * import binding that is a module namespace, or one from outside the repo? + * namespace import, a default binding to a namespace-only module, or one + * from outside the repo? A require of `exports.x = …` is a default binding, + * but its absent members are no unrelated class's methods. */ -function isImportedModuleReceiver(receiver: string, ref: UnresolvedRef, context: ResolutionContext): boolean { +function isImportedModuleReceiver(receiver: string, member: string, ref: UnresolvedRef, context: ResolutionContext): boolean { const root = receiver.split('.')[0]!; const binding = context.getImportMappings?.(ref.filePath, ref.language)?.find((m) => m.localName === root); if (!binding) return false; - return binding.isNamespace || context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; + // A class declared in this lexical scope shadows a file-wide import. It + // still gets the direct class lookup below; an unrelated file's class does not. + const locals = context.getNodesInFileNamed?.(ref.filePath, root) ?? + context.getNodesInFile(ref.filePath).filter((n) => n.name === root); + if (locals.some((n) => n.kind === 'class' && isLexicallyReachable(n, ref, context) && + holderScopeAt(n, ref, false, context) !== null)) return false; + const module = binding.isDefault ? defaultModuleNamespace(binding.source, ref.filePath, ref.language, context) : null; + const firstMember = receiver.includes('.') ? receiver.slice(root.length + 1).split('.')[0]! : member; + return binding.isNamespace || + (module?.namespace === true && !module.members.has(firstMember)) || + context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; +} + +interface ModuleNamespaceInfo { + namespace: boolean; + members: ReadonlySet; + files: ReadonlySet; + truncated?: boolean; +} + +const JS_MODULE_NAMESPACES = new WeakMap>(); + +/** Only a known namespace, never a module's stated or uncertain default value. + * Conservative raw checks also exclude comments/text mentioning a default: + * uncertainty must preserve existing value resolution, not invent its type. + */ +function defaultModuleNamespace(source: string, from: string, language: Language, context: ResolutionContext, seen = new Set()): ModuleNamespaceInfo { + const file = context.resolveModuleFile?.(source, from, language); + const files = new Set(file ? [file] : []); + const members = new Set(); + if (!file || !/\.[cm]?[jt]sx?$/.test(file)) return { namespace: false, members, files }; + if (seen.has(file) || seen.size >= 8) return { namespace: false, members, files, truncated: true }; + seen.add(file); + let memo = JS_MODULE_NAMESPACES.get(context); + if (!memo) JS_MODULE_NAMESPACES.set(context, (memo = new Map())); + const key = `${file}\0${language}`; + const cached = memo.get(key); + if (cached !== undefined) return cached; + const code = context.readFile(file); + let info: ModuleNamespaceInfo = { namespace: false, members, files }; + if (code !== null && !/\bdefault\b|\bexport\s*=/.test(code)) { + // Follow only a whole-file forwarder; extra assignments or other code + // make the effective default uncertain. Named module.exports.x writes + // still describe the namespace, whereas any other use of module may + // replace it (including computed writes or assignment through an alias). + const forwarded = /^\s*(?:['"]use strict['"];\s*)?(?:exports\s*=\s*)?module\.exports\s*=\s*(?:exports\s*=\s*)?require\(\s*['"]([^'"]+)['"]\s*\)\s*;?\s*$/.exec(code); + if (forwarded) { + const next = defaultModuleNamespace(forwarded[1]!, file, language, context, seen); + info = { ...next, files: new Set([...files, ...next.files]) }; + } else { + const exported = readCommonJsExportMembers(code, /\.(?:[cm]?js|[jt]sx)$/.test(file)); + info = { namespace: !exported.uncertain, members: exported.members, files }; + } + } + // A depth/cycle cutoff says nothing about a shorter lookup of this file. + if (!info.truncated) memo.set(key, info); + return info; } const JS_ASSIGNED_MEMBERS = new WeakMap>>(); @@ -32147,7 +32205,20 @@ export function matchReference( // basicRequest.get(…).response(asStringAlways)` is a request's `response`. if (result && result.targetNodeId === ref.fromNodeId && ref.referenceKind === 'calls' && VALUE_KINDS.has(context.getNodeById?.(ref.fromNodeId)?.kind ?? '')) return null; - return result ? retargetSelfOverload(result, ref, context) : result; + const target = result ? retargetSelfOverload(result, ref, context) : result; + // Class-name matches also depend on imported namespace ownership, even + // though they do not carry the method-name strategy's byName marker. + if (target && ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language)) { + const root = /^([A-Za-z_$][\w$]*)\./.exec(ref.referenceName)?.[1]; + const binding = root && context.getImportMappings?.(ref.filePath, ref.language)?.find((m) => m.isDefault && m.localName === root); + if (binding) { + const module = defaultModuleNamespace(binding.source, ref.filePath, ref.language, context); + const previous = Array.isArray(target.metadata?.typeFrom) ? target.metadata.typeFrom.filter((f): f is string => typeof f === 'string') : []; + const files = new Set([...previous, ...module.files]); + if (files.size) return { ...target, metadata: { ...target.metadata, typeFrom: [...files].sort() } }; + } + } + return target; } /**