From 913fb3ffe795017120050a8ddedc2614d93f5b32 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 17:28:35 +0300 Subject: [PATCH] fix(resolution): keep JS/TS built-in method calls off lone project methods The name-only method fallback bound an untyped `lines.map()`, `seen.has(k)` or `p.then()` to whichever project class declared the only `map`/`has`/`then` (confidence 0.7), or to a same-named method via receiver/qualified-name word overlap (0.65). On codegraph's own index that fabricated ~1,200 call edges, most of them into GraphAdapter::map and LRUCache::has/set. For TS/JS-family refs whose method name is a common Array/Map/Set/String/ Promise/RegExp/Object prototype method, the fallback now requires the receiver's words to name a candidate's class (`treeCache.get` -> LRUCache keeps its edge) and otherwise declines. Typed receivers, `this.field` calls and `Class.method` matches resolve before this fallback and are unchanged. Co-Authored-By: Claude Opus 5.5 --- __tests__/js-builtin-method-calls.test.ts | 137 ++++++++++++++++++++++ src/resolution/js-builtins.ts | 30 +++++ src/resolution/name-matcher.ts | 26 +++- 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 __tests__/js-builtin-method-calls.test.ts diff --git a/__tests__/js-builtin-method-calls.test.ts b/__tests__/js-builtin-method-calls.test.ts new file mode 100644 index 0000000000..0433cf6875 --- /dev/null +++ b/__tests__/js-builtin-method-calls.test.ts @@ -0,0 +1,137 @@ +/** + * A TS/JS member call with an untyped receiver whose method name is a common + * built-in prototype method — `lines.map()`, `seen.has(k)`, `p.then()` — is + * the built-in, not whichever project class happens to declare the only + * `map`/`has`/`then`. The name-only method fallback used to bind it anyway. + * Typed receivers and receivers whose name names the class keep their edge. + */ + +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'; + +let dir: string; +let cg: CodeGraph; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-builtin-methods-')); + const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, rel), body); + w( + 'adapter.ts', + 'export class GraphAdapter {\n' + + ' async map(req?: string) { return req ?? ""; }\n' + + ' async filter() { return 1; }\n' + + '}\n' + ); + w( + 'lru.ts', + 'export class LRUCache {\n' + + ' private m = new Map();\n' + + ' get(k: K): V | undefined { return this.m.get(k); }\n' + + ' has(k: K): boolean { return this.m.has(k); }\n' + + ' evictOldest(): void {}\n' + + '}\n' + ); + w( + 'use.ts', + 'export function names(items: { name: string }[]): string[] {\n' + + " const lines = 'a\\nb'.split('\\n');\n" + + ' const upper = lines.map((l) => l.toUpperCase());\n' + + ' return items.filter((i) => i.name).map((i) => i.name).concat(upper);\n' + + '}\n' + ); + // Plain JS: no annotations, so the receiver type is unknown to the resolver. + w( + 'plain.js', + 'export class Registry { has(k) { return !!k; } }\n' + + 'export class Deferred { then(cb) { cb(); } }\n' + + 'export function dedupe(xs, seen) {\n' + + ' const out = [];\n' + + ' for (const x of xs) { if (seen.has(x)) continue; seen.add(x); out.push(x); }\n' + + ' return out;\n' + + '}\n' + + 'export function later(pending) {\n' + + ' pending.then(() => undefined);\n' + + '}\n' + ); + w( + 'owner.ts', + "import { LRUCache } from './lru';\n" + + 'export class Owner {\n' + + ' private cache: LRUCache = new LRUCache();\n' + + ' lookup(k: string): number | undefined { return this.cache.get(k); }\n' + + '}\n' + + 'export function local(k: string): boolean {\n' + + ' const c = new LRUCache();\n' + + ' return c.has(k);\n' + + '}\n' + + 'export function viaParam(c: LRUCache, k: string): boolean {\n' + + ' return c.has(k);\n' + + '}\n' + + 'export function named(treeCache: any, k: string): unknown {\n' + + ' return treeCache.get(k);\n' + + '}\n' + + 'export function evict(store: any): void {\n' + + ' store.evictOldest();\n' + + '}\n' + ); + cg = await CodeGraph.init(dir, { index: true }); + cg.resolveReferences(); +}); + +afterAll(() => { + cg.destroy(); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway. + } +}); + +const calleesOf = (name: string, kind: 'function' | 'method' = 'function') => { + const caller = cg.getNodesByKind(kind).find((n) => n.name === name); + expect(caller).toBeDefined(); + return cg + .getCallees(caller!.id) + .filter(({ edge }) => edge.kind === 'calls') + .map(({ node }) => node.qualifiedName) + .sort(); +}; + +describe('TS/JS built-in method names on untyped receivers', () => { + it('does not bind Array map/filter to a lone project method of that name', () => { + const callees = calleesOf('names'); + expect(callees).not.toContain('GraphAdapter::map'); + expect(callees).not.toContain('GraphAdapter::filter'); + }); + + it('does not bind Set.has to a project class has', () => { + expect(calleesOf('dedupe')).not.toContain('Registry::has'); + }); + + it('does not bind Promise.then to a project class then', () => { + expect(calleesOf('later')).not.toContain('Deferred::then'); + }); + + it('keeps a typed class field call (this.cache.get)', () => { + expect(calleesOf('lookup', 'method')).toContain('LRUCache::get'); + }); + + it('keeps a call on a local constructed from the project class', () => { + expect(calleesOf('local')).toContain('LRUCache::has'); + }); + + it('keeps a call on a parameter typed as the project class', () => { + expect(calleesOf('viaParam')).toContain('LRUCache::has'); + }); + + it('keeps a call whose receiver name names the class', () => { + expect(calleesOf('named')).toContain('LRUCache::get'); + }); + + it('keeps a lone project method whose name is not a built-in', () => { + expect(calleesOf('evict')).toContain('LRUCache::evictOldest'); + }); +}); diff --git a/src/resolution/js-builtins.ts b/src/resolution/js-builtins.ts index f02a9742d9..4a3fbe0eaf 100644 --- a/src/resolution/js-builtins.ts +++ b/src/resolution/js-builtins.ts @@ -18,3 +18,33 @@ export const TS_PRIMITIVE_TYPES = new Set([ 'string', 'number', 'boolean', 'bigint', 'symbol', 'void', 'undefined', 'null', 'never', 'unknown', 'any', 'object', ]); + +/** + * Methods on the JS/TS built-in prototypes (Array, Map/Set, String, Promise, + * RegExp, Object) that project classes also commonly declare. A call such as + * `lines.map(...)` or `seen.has(k)` whose receiver type is unknown is far more + * likely the built-in than whichever project class happens to declare the + * only `map`/`has`, so the name-only method fallback needs receiver evidence + * before it binds one of these names to a project method. + */ +export const JS_BUILTIN_METHOD_NAMES = new Set([ + // Array + 'map', 'filter', 'forEach', 'reduce', 'reduceRight', 'find', 'findIndex', + 'findLast', 'findLastIndex', 'some', 'every', 'includes', 'indexOf', + 'lastIndexOf', 'join', 'slice', 'splice', 'concat', 'push', 'pop', 'shift', + 'unshift', 'sort', 'reverse', 'flat', 'flatMap', 'fill', 'at', + 'keys', 'values', 'entries', + // Map / Set + 'get', 'set', 'has', 'delete', 'clear', 'add', + // String + 'split', 'trim', 'trimStart', 'trimEnd', 'replace', 'replaceAll', + 'startsWith', 'endsWith', 'toLowerCase', 'toUpperCase', 'padStart', 'padEnd', + 'substring', 'match', 'matchAll', 'charAt', 'charCodeAt', 'localeCompare', + 'repeat', + // Promise + 'then', 'catch', 'finally', + // RegExp + 'exec', 'test', + // Object + 'toString', 'valueOf', 'hasOwnProperty', +]); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5c0bfac7d6..7982cf20d8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; -import { JS_BUILT_INS, TS_PRIMITIVE_TYPES } from './js-builtins'; +import { JS_BUILT_INS, JS_BUILTIN_METHOD_NAMES, TS_PRIMITIVE_TYPES } from './js-builtins'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -2614,6 +2614,8 @@ export function matchMethodCall( const sameLanguageMethods = methods.filter(m => m.language === ref.language); const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods; + if (isUnevidencedJsBuiltinMethod(targetMethods, objectOrClass!, methodName!, ref)) return null; + // If only one same-language method with this name exists, use it if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) { return { @@ -3238,6 +3240,28 @@ function splitCamelCase(str: string): string[] { .filter(w => w.length > 1); } +/** + * JS/TS name-only fallback guard: `lines.map()` / `seen.has(k)` with an + * untyped receiver is almost always the built-in prototype method, so a + * project class's lone `map`/`has` is not evidence enough. Decline unless the + * receiver's words name a candidate's class (`treeCache.get` → `LRUCache`). + * Typed receivers, `this.field` calls and `Class.method` matches are resolved + * before Strategy 3 and never reach this check. + */ +function isUnevidencedJsBuiltinMethod( + candidates: Node[], + receiver: string, + methodName: string, + ref: UnresolvedRef +): boolean { + if (!ESM_FAMILY.has(ref.language) || !JS_BUILTIN_METHOD_NAMES.has(methodName)) return false; + const receiverWords = splitCamelCase(receiver).map((w) => w.toLowerCase()); + return !candidates.some((m) => { + const owner = m.qualifiedName.split('::').slice(0, -1).join('::'); + return splitCamelCase(owner).some((w) => receiverWords.includes(w.toLowerCase())); + }); +} + /** * Compute directory proximity from a pre-split list of directory segments * (`filePath1` minus its filename) and a second file path.