Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions __tests__/js-builtin-method-calls.test.ts
Original file line number Diff line number Diff line change
@@ -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<K, V> {\n' +
' private m = new Map<K, V>();\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<string, number> = 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<string, number>();\n' +
' return c.has(k);\n' +
'}\n' +
'export function viaParam(c: LRUCache<string, number>, 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');
});
});
30 changes: 30 additions & 0 deletions src/resolution/js-builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
26 changes: 25 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down