Files
codegraph/__tests__/js-function-local-binding.test.ts
T
Colby MchenryandClaude Opus 5.5 73f68e188c fix(ts): a call to a parameter of any type links nothing elsewhere (#2732)
A JS/TS name the calling function binds as a parameter is that parameter,
never a same-named function in another file, as the file's own were made
in #2226. The parameter reader took a type or default to be anything up to
a comma or a parenthesis, so a parameter typed as a function, defaulted to
one, or after one was never seen: bitwarden's isAvailable(f) on a callback
typed (f: string) => boolean linked to the napi isAvailable, and vscode's
merge editor's compare(a, b) on a comparator parameter to strings.ts's.

A type or default may now hold balanced parentheses, braces, brackets and
generics, a parameter property's modifiers are read, and an earlier
destructured parameter is passed over; each alternative still has one
parse. A name ends where no identifier character follows, so a const
syncState$ no longer declares syncState.

The parameter also shadows the file's own import of the name, which the
import resolver answered before the check ran, but only where it is in
scope: in the body of the function, arrow or catch clause that binds it,
or the block or for statement of a const, let, var, function or class,
read from the function's text up to the reference. A lone arrow
parameter counts, and an object type a function returns is no body. A
Promise executor's resolve is the calls inside it, never a resolve(file)
after it, and a const X = require('./x').default is the import itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:08:54 +00:00

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A JS/TS name the calling function binds itself — a parameter, or a `var` /
* `let` / `const` above the reference — is that local, never a same-named
* function declared elsewhere in the file. Every lodash helper lives inside
* `runInContext`, so `baseHas(object, key)`'s `object` and `mixin`'s
* `object(this.__wrapped__)` reached a `function object() {}` an IIFE declares
* there. A function that calls itself through its own `const` still does, and
* `if (handler) handler()` is not a parameter list.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-local-'));
const files: Record<string, string> = {
'lodash.js': `function runInContext(context) {
var baseCreate = (function() {
function object() {}
return function(proto) {
object.prototype = proto;
return new object;
};
}());
function baseHas(object, key) {
return object != null && hasOwnProperty.call(object, key);
}
function mixin(object, source) {
var result = object(this.__wrapped__);
return result;
}
function handler() {}
function run() {
if (handler) {
handler();
}
const walk = (node) => (node ? walk(node.next) : null);
return walk(context);
}
return { baseCreate, baseHas, mixin, run };
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const targetsFrom = (qualifiedName: string) => {
const ids = cg.getNodesInFile('lodash.js').filter((n) => n.qualifiedName === qualifiedName).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!.qualifiedName);
};
describe('JS names the calling function binds', () => {
it('are its parameters, not a same-named function elsewhere in the file', () => {
expect(targetsFrom('runInContext::baseHas')).not.toContain('runInContext::object');
expect(targetsFrom('runInContext::mixin')).not.toContain('runInContext::object');
});
it('leave unbound names and a const’s own recursion alone', () => {
expect(targetsFrom('runInContext::run')).toContain('runInContext::handler');
expect(targetsFrom('runInContext::run::walk')).toContain('runInContext::run::walk');
});
});
/**
* The same holds for a TypeScript parameter whose type or default holds
* parentheses, generics or braces, and across files: bitwarden's
* `isAvailable(f)` on a callback parameter typed `(f: string) => boolean`
* reached the napi `isAvailable` another file exports, and vscode's merge
* editor's `compare(a, b)` on a comparator parameter a `strings.ts`
* `compare`. A parameter shadows the file's own import of the name too, but
* only where it is in scope: inside its function's or arrow's body, never
* after it. Neither does `const syncState$` declare `syncState`, nor `const
* X = require('./x').default` hide the import it is.
*/
describe('TS parameters of any type are the calling function’s, in any file', () => {
let tsRoot = '';
let ts: CodeGraph;
beforeAll(async () => {
tsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-local-ts-'));
const files: Record<string, string> = {
'src/sdk.ts': 'export function isAvailable(format: string): boolean { return format.length > 0; }\n',
'src/strings.ts': 'export function compare(a: number, b: number): number { return a - b; }\n',
'src/use.ts': `import { isAvailable } from './sdk';
export function check(format: string): boolean {
return isAvailable(format);
}
export function pick(formats: string[], isAvailable: (f: string) => boolean): string[] {
return formats.filter((f) => isAvailable(f));
}
export function pickDefault(formats: string[], isAvailable = (f: string) => f.length > 0): string[] {
return formats.filter((f) => isAvailable(f));
}
`,
'src/sort.ts': `export const compareAll = (items: number[], compare: (a: number, b: number) => number) =>
items.sort((a, b) => compare(a, b));
export function later(cb: () => void, options: Map<string, number>, compare: unknown): void {
cb();
(compare as (a: number, b: number) => number)(options.size, 0);
}
export class Sorter {
constructor(private readonly compare: (a: number, b: number) => number) {
compare(1, 2);
}
}
`,
'src/other.ts': `export function sortAll(items: number[]): number[] {
return items.sort((a, b) => compare(a, b));
}
`,
'src/state.ts': 'export function syncState(obs: object): object { return obs; }\n',
'src/sync.ts': `import { syncState } from './state';
export function syncObservable(obs$: object): object {
const syncState$ = syncState(obs$);
return syncState$;
}
export function syncLater(items: object[]): object {
items.forEach((syncState) => console.log(syncState));
return syncState(items);
}
export function syncInside(items: object[]): void {
items.forEach((syncState: (o: object) => object) => {
syncState(items);
});
}
`,
'src/paths.ts': 'export function resolve(...parts: string[]): string { return parts.join("/"); }\n',
// No semicolons after the arrow in fmtAll, as a project formatted without them writes it.
'src/run.ts': `import { resolve } from './paths';
export function run(file: string): Promise<void> {
return new Promise((resolve) => {
resolve(file);
});
}
export function runThen(file: string): string {
new Promise<void>((resolve) => setTimeout(resolve, 0));
return resolve(file, 'out');
}
export function fmtAll(items: string[]): string {
const fmt = (resolve: string) => resolve.trim()
return resolve(fmt(items[0]))
}
export function eachInLoop(files: string[]): void {
for (const resolve of [String]) resolve(files[0]);
}
export function eachAfterLoop(files: string[]): string {
for (const resolve of [String]) String(resolve);
return resolve(files[1]);
}
export function bare(): Promise<void> {
return new Promise<void>(resolve => {
resolve();
});
}
export function bareThen(file: string): string {
new Promise<void>(resolve => setTimeout(resolve, 0));
return resolve(file);
}
export function nested(file: string): string {
function resolve(p: string): string { return p; }
return resolve(file);
}
`,
'src/widget.ts': 'export class Widget {}\nexport type Size = { width: number };\n',
// A return type's name is no lone arrow parameter, and a value never hides a type.
'src/build.ts': `import { Widget, type Size } from './widget';
export const buildWidget = (name: string): Widget => {
return new Widget();
};
export function measure(Size: number): number {
const size: Size = { width: Size };
return size.width;
}
`,
// The object type a function returns is no body: withShape's own one still holds its parameter.
'src/shape.ts': `import { compare } from './strings';
export function withShape(compare: (a: number, b: number) => number): { sorted: number[]; size: number } {
return { sorted: [compare(1, 2)], size: 1 };
}
export function plain(a: number, b: number): number {
return compare(a, b);
}
`,
'src/biometrics.ts': `export default class OsBiometrics {
constructor(readonly name: string) {}
}
`,
// describeBiometrics binds the name too, so the file's bindings alone don't settle init's.
'src/service.ts': `export class BiometricsService {
private service: object | undefined;
init(platform: string): void {
if (platform === 'darwin') {
const OsBiometrics = require('./biometrics').default;
this.service = new OsBiometrics('mac');
}
}
}
export function describeBiometrics(OsBiometrics: unknown): string {
return String(OsBiometrics);
}
`,
// An anonymous IIFE's calls are the file's own: only the file's view of
// its bindings sees the parameter, after a function-typed one.
'src/iife.ts': `((done: () => void, compare: (a: number, b: number) => number) => {
compare(1, 2);
done();
})(() => {}, (a, b) => a - b);
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(tsRoot, rel)), { recursive: true });
fs.writeFileSync(path.join(tsRoot, rel), content);
}
ts = await CodeGraph.init(tsRoot, { index: true });
});
afterAll(() => {
ts?.close();
if (tsRoot) fs.rmSync(tsRoot, { recursive: true, force: true });
});
/** `<file>:<qualified name>` of every call (or edge of `kind`) the named symbol in `file` makes. */
const callsFrom = (file: string, name: string, kind = 'calls'): string[] => {
const node = ts.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file')!;
expect(node, `${file} ${name}`).toBeDefined();
return ts.getOutgoingEdges(node.id).filter((e) => e.kind === kind).map((e) => {
const target = ts.getNode(e.target)!;
return `${target.filePath}:${target.qualifiedName}`;
});
};
it('a parameter typed as a function, or defaulted to one, calls what it is given', () => {
expect(callsFrom('src/use.ts', 'pick')).toEqual([]);
expect(callsFrom('src/use.ts', 'pickDefault')).toEqual([]);
expect(callsFrom('src/sort.ts', 'compareAll')).toEqual([]);
});
it('after earlier parameters with function and generic types, and as a parameter property', () => {
expect(callsFrom('src/sort.ts', 'later')).toEqual([]);
expect(callsFrom('src/sort.ts', 'constructor')).toEqual([]);
});
it('shadows the file’s import of the name, which a function binding nothing still calls', () => {
expect(callsFrom('src/use.ts', 'check')).toEqual(['src/sdk.ts:isAvailable']);
});
it('in code no function node holds, the parameter list the call sits in', () => {
const file = ts.getNodesInFile('src/iife.ts').find((n) => n.kind === 'file')!;
const calls = ts.getOutgoingEdges(file.id).filter((e) => e.kind === 'calls').map((e) => ts.getNode(e.target)!.filePath);
expect(calls).not.toContain('src/strings.ts');
});
it('leaves a name no function binds to the matcher', () => {
expect(callsFrom('src/other.ts', 'sortAll')).toEqual(['src/strings.ts:compare']);
});
it('shadows an import only inside the body that binds the name', () => {
expect(callsFrom('src/sync.ts', 'syncInside')).toEqual([]);
expect(callsFrom('src/run.ts', 'run')).toEqual([]);
expect(callsFrom('src/run.ts', 'eachInLoop')).toEqual([]);
expect(callsFrom('src/sync.ts', 'syncLater')).toEqual(['src/state.ts:syncState']);
expect(callsFrom('src/run.ts', 'runThen')).toEqual(['src/paths.ts:resolve']);
expect(callsFrom('src/run.ts', 'eachAfterLoop')).toEqual(['src/paths.ts:resolve']);
});
it('ends an arrow’s expression body at a line that starts the next statement', () => {
expect(callsFrom('src/run.ts', 'fmtAll')).toContain('src/paths.ts:resolve');
});
it('reads a lone arrow parameter, a nested function and a body after an object return type', () => {
expect(callsFrom('src/run.ts', 'bare')).toEqual([]);
expect(callsFrom('src/run.ts', 'bareThen')).toEqual(['src/paths.ts:resolve']);
expect(callsFrom('src/run.ts', 'nested')).not.toContain('src/paths.ts:resolve');
expect(callsFrom('src/shape.ts', 'withShape')).toEqual([]);
expect(callsFrom('src/shape.ts', 'plain')).toEqual(['src/strings.ts:compare']);
});
it('takes no return type for a parameter, and lets no value hide a type', () => {
expect(callsFrom('src/build.ts', 'buildWidget', 'instantiates')).toEqual(['src/widget.ts:Widget']);
expect(callsFrom('src/build.ts', 'measure', 'references')).toContain('src/widget.ts:Size');
});
it('reads `syncState$` as no declaration of `syncState`, and a required local as the import', () => {
expect(callsFrom('src/sync.ts', 'syncObservable')).toEqual(['src/state.ts:syncState']);
expect(callsFrom('src/service.ts', 'init', 'instantiates')).toEqual(['src/biometrics.ts:OsBiometrics']);
});
});