Files
codegraph/__tests__/ts-ambient-exports.test.ts
Colby Mchenry e0bbb6620f fix(dead-code): a type that declare module or declare global merges outward is not dead (#2418)
The viewer's Dead code screen listed TypeScript interfaces that augment a
type declared outside the project: chart.js's `PluginOptionsByType` in
`declare module 'chart.js' { … }` (ghostfolio), `interface Window` in
`declare global { … }` (angular-realworld). The library or the runtime
reads them through the type they merge into, so nothing in the repository
names them, and the index recorded them as unexported file-level
interfaces.

TypeScript exports what such a body declares without the keyword. The
binder makes an ambient module body an export context unless it holds an
export declaration (`export {}`, `export =`, `export default x`), and a
namespace nested in one is ambient too. Both extractors now set isExported
that way: isAmbientExport in languages/typescript.ts, shared by TSX and
ArkTS, and is_ambient_export in the kernel's tsjs walker. The exported rule
then drops them, counted under `exported`, and that count's label now
names them. `declare namespace X` is unchanged: in a module file X belongs
to that file.

The resolver sees them as exported too. A file holding `declare global`
was already treated as exporting, so nothing moves there. A `declare
module` member becomes reachable across files by name.

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

209 lines
7.1 KiB
TypeScript

/**
* A declaration inside `declare module 'x' { … }` or `declare global { … }` is
* exported, though nothing in it writes `export`:
*
* declare module 'chart.js' { // ghostfolio's chart.registry.ts
* interface PluginOptionsByType<TType extends ChartType> { … }
* }
* declare global { // angular-realworld's app.config.ts
* interface Window { __conduit_debug__?: ConduitDebug; }
* }
*
* Both indexed as unexported file-level interfaces, so the dead-code report
* listed them: nothing in the repository names them, because chart.js and the
* browser read them through the type they merge into. TypeScript's binder
* makes an ambient module body an export context, and a namespace nested in
* one is ambient too, unless the body holds an export declaration of its own
* (`export {}`, `export =`, `export default x`). Both extractors now say what
* the compiler says. `declare namespace X` is unchanged: in a module file it
* is that file's own namespace and merges with nothing outside. Runs against
* the native kernel (when built) and the wasm extractor, which must agree.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
import type { ExtractionResult, Language } from '../src/types';
const KERNEL_PATH = path.join(
__dirname,
'..',
'codegraph-kernel',
'prebuilds',
`${process.platform}-${process.arch}`,
'codegraph-kernel.node'
);
const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1';
/** A module file: augmentations, a global block, and the namespaces that are neither. */
const MODULE_SOURCE = `import type { ChartType } from 'chart.js';
declare module 'chart.js' {
interface PluginOptionsByType<TType extends ChartType> {
verticalHoverLine: TType;
}
export interface WrittenOut {}
namespace Plugins {
interface Nested {}
}
type Alias = string;
enum Position { Top }
class Positioner {}
const defaults: Alias;
}
declare global {
interface Window {
__conduit_debug__?: unknown;
}
namespace NodeJS {
interface ProcessEnv {
API_URL?: string;
}
}
var bitwardenContainerService: unknown;
}
declare namespace Settings {
interface OwnNamespace {}
}
namespace Plain {
interface Hidden {}
export interface Shown {}
}
interface FileLocal {}
export const appConfig = {};
`;
/**
* A script file declaring a module whole, the way an untyped library's typings
* do. \`export =\` is an export declaration, so the body exports only that.
*/
const SCRIPT_SOURCE = `declare module 'legacy-lib' {
interface Options {}
const main: (options: Options) => void;
export = main;
}
declare module 'listed-lib' {
interface Unlisted {}
export interface Listed {}
export { Unlisted as Renamed };
}
declare module 'open-lib' {
interface Open {}
}
`;
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
/** Kinds a statement declares — the nodes that carry an export flag of their own. */
const DECLARATION_KINDS = new Set(['interface', 'class', 'enum', 'type_alias', 'function', 'variable', 'constant', 'component']);
/** `name` → isExported, for every declaration. */
function exportFlags(result: ExtractionResult): Record<string, boolean> {
const flags: Record<string, boolean> = {};
for (const node of result.nodes) {
if (DECLARATION_KINDS.has(node.kind)) flags[node.name] = node.isExported === true;
}
return flags;
}
describe('TypeScript ambient module bodies export what they declare', () => {
let savedEnv: Record<string, string | undefined> = {};
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['typescript', 'tsx']);
});
beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
resetKernelForTests();
});
afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
resetKernelForTests();
});
function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult {
if (backend === 'wasm') {
process.env.CODEGRAPH_KERNEL = '0';
return extractFromSource(file, source, language);
}
delete process.env.CODEGRAPH_KERNEL;
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
const result = tryKernelExtract(file, source, language);
expect(result, `kernel extraction of ${file}`).not.toBeNull();
return result!;
}
const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const);
for (const crlf of [false, true]) {
const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s);
const label = crlf ? ' (CRLF)' : '';
it.each(backends)(`declare module and declare global export their declarations: %s${label}`, (backend) => {
const result = extract(backend, 'src/chart.registry.ts', eol(MODULE_SOURCE), 'typescript');
expect(exportFlags(result)).toEqual({
// declare module 'chart.js', and a namespace nested in it.
PluginOptionsByType: true,
WrittenOut: true,
Nested: true,
Alias: true,
Position: true,
Positioner: true,
defaults: true,
// declare global, and a namespace nested in it.
Window: true,
ProcessEnv: true,
bitwardenContainerService: true,
// Neither is an outside scope: a module file's own namespaces.
OwnNamespace: false,
Hidden: false,
Shown: true,
FileLocal: false,
appConfig: true,
});
});
it.each(backends)(`a body with an export declaration exports only what it names: %s${label}`, (backend) => {
const result = extract(backend, 'types/legacy.ts', eol(SCRIPT_SOURCE), 'typescript');
expect(exportFlags(result)).toEqual({
Options: false,
main: false,
Unlisted: false,
Listed: true,
Open: true,
});
});
it.each(backends)(`tsx reads the same blocks: %s${label}`, (backend) => {
const source = eol(`declare module '@tanstack/react-router' {\n interface Register {\n router: unknown;\n }\n}\nexport const App = () => <main />;\n`);
const result = extract(backend, 'src/main.tsx', source, 'tsx');
expect(exportFlags(result).Register).toBe(true);
});
}
it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node', () => {
for (const [file, source] of [['src/chart.registry.ts', MODULE_SOURCE], ['types/legacy.ts', SCRIPT_SOURCE]] as const) {
const canon = (r: ExtractionResult) =>
r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())).sort();
expect(canon(extract('kernel', file, source, 'typescript'))).toEqual(
canon(extract('wasm', file, source, 'typescript'))
);
}
});
});