Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a type, class, constant or other declaration that a `.d.ts` file declares inside `declare module '…'` now links only from files that use that module: files that import it, the module's other declarations, and files that import it through a file of yours that re-exports it. Before, any file using the same name linked to it: Node's `import { readFileSync } from 'fs'` linked to the `fs` that VS Code's `vscode.d.ts` declares, and a `ProcessEnv`, or an LSP `TextDocument` imported through a barrel, to the `vscode` module's types of those names. Re-index TypeScript projects after upgrading.
- In C++, a call written through a class the index doesn't hold, like protocolbuffers/protobuf's `FeatureSet::descriptor()` or `OneofOptions::default_instance()` (both classes are declared in `descriptor.pb.h`, a file too large to index), no longer links to another class's method of that name whose class only shares a word with it, like `JavaFeatures_NestInFileClassFeature::descriptor` or `CppFileOptions::default_instance`. A class named like the call's would have been found first, so one that only shares a word is another class. A call through a template parameter, through an alias, including one the calling function or a macro it expands declares, or through a namespace the file opens, aliases or uses links as before, and so does a call of a method only one class in the project declares. Re-index C++ projects after upgrading.
- In C++, a call written through a namespace, like protocolbuffers/protobuf's `absl::StripPrefix(…)` or fmtlib/fmt's `fmt::ptr(p)`, no longer links to a class's method that only shares the function's name, like protobuf's `MultiCasePrefixStripper::StripPrefix` or a test scanner's `ptr()`: a namespace holds functions and types, not methods. A call through a namespace a macro opens now links to what that namespace declares, so fmt's `fmt::ptr(p)` reaches fmt's own `ptr` function and `fmt::day(…)` its `day` type, while a call into a library outside your project, like abseil, links to nothing. A call through a lowercase class, or an alias or typedef of one, links as before. Re-index C++ projects after upgrading.
- In TypeScript and JavaScript, a call to a function's own parameter, like `isAvailable(f)` on a parameter typed `(f: string) => boolean` or `compare(a, b)` on a comparator parameter, no longer links to a function of the same name in another file, or to the file's own import of that name. Before, a parameter whose type or default value contained parentheses, as a function type does, wasn't recognized as one. A parameter or local stands in for an import of its name only where it is in scope, so a Promise executor's `resolve(…)` no longer links to an imported `path.resolve`, while a `resolve(file)` after the executor still does. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`.

## [1.6.2] - 2026-10-03

Expand Down
225 changes: 225 additions & 0 deletions __tests__/js-function-local-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,228 @@ describe('JS names the calling function binds', () => {
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']);
});
});
6 changes: 5 additions & 1 deletion src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
Expand Down Expand Up @@ -1356,6 +1356,10 @@ export class ReferenceResolver {
const module = this.nodeById(resolved.targetNodeId);
if (module && module.kind !== 'file' && module.kind !== 'module') return null;
}
// A name the calling JS/TS function binds itself (a parameter, a local) in
// scope at the reference is that binding even when the file imports the
// name, which the import resolver answers before the matcher's own check.
if (resolved && resolved.resolvedBy === 'import' && isImportShadowedByJsBinding(resolved, ref, this.context)) return null;
// A declaration of a declaration file's `declare module 'x'` is reached
// through an import of `x`, never by its bare name from a file that
// reaches no `x` (see reachesAmbientModule).
Expand Down
Loading