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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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 `$`.
- In TypeScript, a type a file uses without importing it, like `Omit<T, K>`, `Record<K, V>`, the DOM's `Event`, or a generic's own `T`, no longer links to a type of that name another module of your project declares, like a local `export type Omit` or an enum member `T`: it links to the file's own type or a global one, declared in a script `.d.ts` or a `declare global` block. A type you import, write qualified (`vscode.TextDocument`), bring in with `import X = ns.X`, or name inside a `declare module` block links as before. Re-index TypeScript projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
6 changes: 4 additions & 2 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7011,13 +7011,15 @@ describe('Cross-language type/import gate (RN name collisions)', () => {
// TS name-match — so a TS ref to `TestRunner` crossed web→jvm. The ref here
// is intentionally NOT imported: a clean relative import would mask the bug
// by resolving via the import map before the framework strategy can win.
// So the TS type is a global, declared in a script `.d.ts`: the one kind of
// type a module names without importing it.
fs.writeFileSync(
path.join(tempDir, 'package.json'),
JSON.stringify({ dependencies: { 'react-native': '*' } })
);
fs.writeFileSync(
path.join(tempDir, 'useTests.ts'),
`export type TestRunner = { run: () => void };\n`
path.join(tempDir, 'useTests.d.ts'),
`type TestRunner = { run: () => void };\n`
);
fs.writeFileSync(
path.join(tempDir, 'basic.tsx'),
Expand Down
165 changes: 165 additions & 0 deletions __tests__/ts-type-reference-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* A type an ES module names without importing it is the module's own or a
* global one — never another module's type of that name.
*
* // quickInput.ts
* export type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
* // picker.ts
* export function pick(options: Omit<IPickOptions, 'canPickMany'>) {}
*
* picker.ts's `Omit` is TypeScript's own, yet a type annotation's name went
* to the name matcher like any other, which handed it the one project type
* of that name: 336 of vscode's annotations reached quickInput.ts's `export
* type Omit`. #2726 held a supertype to this rule; a type annotation, a type
* argument and a type alias's value now follow it too. A type the file
* declares, one a script's top level or a `declare global` block declares,
* and the last name of a qualified type (`qi.IPickOptions`) link as before.
*/
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-ts-type-scope-'));
const files: Record<string, string> = {
'src/quickInput.ts': `export type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
export interface IPickOptions { canPickMany?: boolean; title?: string }
`,
'src/events.ts': 'export class Event { constructor(readonly type: string) {} }\n',
'src/picker.ts': `import { IPickOptions } from './quickInput';
type Local = { id: string };
export function pick(options: Omit<IPickOptions, 'canPickMany'>): Local {
return { id: options.title ?? '' };
}
export function onClick(handler: (e: Event) => void): void {
void handler;
}
export type Picked = Omit<IPickOptions, 'title'>;
`,
// An augmentation's names are the augmented module's own, imported or not.
'src/react/reactInterfaces.ts': 'export type FCReactive<P, P2> = P & P2;\nexport interface IReactive { base?: string }\n',
'src/types/reactive-native.d.ts': `import type { IReactive } from '../react/reactInterfaces';
declare module '../react/reactInterfaces' {
interface IReactive {
View: FCReactive<IReactive, { style?: string }>;
}
}
`,
// An `import X = ns.X` alias imports its name, as bitwarden's desktop code does napi's classes.
'src/napi.d.ts': 'export declare namespace autofill {\n export class IpcServer { listen(): void }\n}\n',
'src/alias.ts': `import { autofill } from './napi';
import IpcServer = autofill.IpcServer;
export function serve(server: IpcServer): void {
void server;
}
`,
// Only the namespace is imported: `IPickOptions` is recorded alone, written qualified.
'src/qualified.ts': `import * as qi from './quickInput';
export function qualified(options: qi.IPickOptions): void {
void options;
}
`,
// A script declaration file (no import or export): its declarations are global.
'src/typings/globals.d.ts': 'interface GlobalThing { id: string }\n',
// A module's `declare global` block, and a module-scoped namesake nearer the user.
'src/env.ts': `export {};
declare global {
interface AppConfig { debug: boolean }
}
`,
'src/app/config.ts': 'export interface AppConfig { port: number }\n',
'src/app/use.ts': `export function useGlobal(thing: GlobalThing): string {
return thing.id;
}
export function useConfig(config: AppConfig): boolean {
return config.debug;
}
export function useScripts(thing: LegacyThing, fixture: FixtureGlobal): number {
return thing.id + fixture.x;
}
export const firstOf = <T>(items: T[]): T | undefined => items[0];
export function useShared(shape: SharedShape): string {
return shape.id;
}
`,
// A global both the project and a nearer fixture declare: the project's wins, ahead of the ranking.
'types/shared.d.ts': 'interface SharedShape { id: string }\n',
'src/app/__tests__/shared-fixture.d.ts': 'interface SharedShape { id: string; fixture: true }\n',
// A script .ts is no declaration file: its top level is global to its own program only.
'src/legacy/globals.ts': 'interface LegacyThing { id: number }\n',
// A fixture's globals are its tests' — a declaration file's, and a colorize-style script's `type T`.
'test/fixtures/fixture.d.ts': 'interface FixtureGlobal { x: number }\n',
'test/fixtures/colorize.ts': 'const n = null;\ntype T = null | undefined;\n',
'test/use-fixture.test.ts': `export function check(fixture: FixtureGlobal): number {
return fixture.x;
}
`,
};
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 });
});

/** `<file>:<qualified name>` of every type the named symbol in `file` references. */
const referencesFrom = (file: string, name: string): string[] => {
const node = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file')!;
expect(node, `${file} ${name}`).toBeDefined();
return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'references').map((e) => {
const target = cg.getNode(e.target)!;
return `${target.filePath}:${target.qualifiedName}`;
});
};

describe('a type a TS module names without importing it', () => {
it('is no other module’s type of that name: TypeScript’s Omit, the DOM’s Event', () => {
const picked = referencesFrom('src/picker.ts', 'pick');
expect(picked).not.toContain('src/quickInput.ts:Omit');
expect(referencesFrom('src/picker.ts', 'onClick')).not.toContain('src/events.ts:Event');
expect(referencesFrom('src/picker.ts', 'Picked')).not.toContain('src/quickInput.ts:Omit');
});

it('is the module’s own type, and an imported one still links through its import', () => {
const picked = referencesFrom('src/picker.ts', 'pick');
expect(picked).toContain('src/picker.ts:Local');
expect(picked).toContain('src/quickInput.ts:IPickOptions');
expect(referencesFrom('src/picker.ts', 'Picked')).toContain('src/quickInput.ts:IPickOptions');
});

it('is a global: a script’s top level or a declare global block, ahead of a nearer module’s namesake', () => {
expect(referencesFrom('src/app/use.ts', 'useGlobal')).toContain('src/typings/globals.d.ts:GlobalThing');
const config = referencesFrom('src/app/use.ts', 'useConfig');
expect(config).toContain('src/env.ts:AppConfig');
expect(config).not.toContain('src/app/config.ts:AppConfig');
});

it('leaves the last name of a qualified type to link as before', () => {
expect(referencesFrom('src/qualified.ts', 'qualified')).toContain('src/quickInput.ts:IPickOptions');
});

it('leaves a name inside a declare module block to the module it declares', () => {
expect(referencesFrom('src/types/reactive-native.d.ts', 'View')).toContain('src/react/reactInterfaces.ts:FCReactive');
});

it('is no script .ts’s top-level type, nor a fixture’s outside the tests', () => {
expect(referencesFrom('src/app/use.ts', 'useScripts')).toEqual([]);
expect(referencesFrom('src/app/use.ts', 'firstOf')).not.toContain('test/fixtures/colorize.ts:T');
expect(referencesFrom('test/use-fixture.test.ts', 'check')).toContain('test/fixtures/fixture.d.ts:FixtureGlobal');
expect(referencesFrom('src/app/use.ts', 'useShared')).toEqual(['types/shared.d.ts:SharedShape']);
});

it('counts an import X = ns.X alias as an import of its name', () => {
expect(referencesFrom('src/alias.ts', 'serve').some((t) => t.startsWith('src/napi.d.ts:') && t.endsWith('IpcServer'))).toBe(true);
});
});
21 changes: 18 additions & 3 deletions src/resolution/ambient-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ export interface GlobalScopeScan {
* `abstract`, `enum`, `function`, `const`, …).
*/
globalWords: Set<string>;
/** The file's `declare module '<name>' { … }` blocks, whose names are the declared module's. */
moduleBlocks: readonly AmbientModuleBlock[];
/** Names an `import X = …` declaration binds: `import AutofillIpcServer = autofill.AutofillIpcServer`. */
importAliases: Set<string>;
}

/**
Expand All @@ -244,11 +248,22 @@ export interface GlobalScopeScan {
* not in it.
*/
export function scanGlobalScope(source: string): GlobalScopeScan {
const { script } = scanAmbientModules(source);
const { script, blocks: moduleBlocks } = scanAmbientModules(source);
const globalWords = new Set<string>();
const importAliases = new Set<string>();
let declare = 0; // 1: `declare`, 2: `declare global`
let inGlobal = false;
let alias = 0; // 1: `import` (or `import type`), 2: `import X`
let aliasName = '';
tokenize(source, (kind, text, line, column, depth) => {
if (kind === Kind.Word && text === 'import') alias = 1;
else if (alias === 1 && kind === Kind.Word && text !== 'type') {
alias = 2;
aliasName = text;
} else if (alias === 2 && kind === Kind.Punct && text === '=') {
importAliases.add(aliasName);
alias = 0;
} else if (!(alias === 1 && kind === Kind.Word)) alias = 0;
if (inGlobal) {
if (kind === Kind.Punct && text === '}' && depth === 0) inGlobal = false;
else if (kind === Kind.Word && depth === 1) globalWords.add(`${line}:${column}`);
Expand All @@ -266,7 +281,7 @@ export function scanGlobalScope(source: string): GlobalScopeScan {
if (kind === Kind.Punct && text === '{' && declare === 2) inGlobal = true;
declare = 0;
});
return { script, globalWords };
return { script, globalWords, moduleBlocks, importAliases };
}

/**
Expand Down Expand Up @@ -519,7 +534,7 @@ function scanned(context: ResolutionContext): NonNullable<Memo['scanned']> {
const DECLARATION_KINDS = new Set<Node['kind']>(['function', 'class', 'interface', 'type_alias', 'enum', 'constant', 'variable', 'component']);

/** Whether `node` starts inside the block, by position. */
function inBlock(node: Pick<Node, 'startLine' | 'startColumn'>, block: AmbientModuleBlock): boolean {
export function inBlock(node: Pick<Node, 'startLine' | 'startColumn'>, block: AmbientModuleBlock): boolean {
const afterStart = node.startLine > block.startLine || (node.startLine === block.startLine && node.startColumn >= block.startColumn);
const beforeEnd = node.startLine < block.endLine || (node.startLine === block.endLine && node.startColumn <= block.endColumn);
return afterStart && beforeEnd;
Expand Down
Loading