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
103 changes: 103 additions & 0 deletions __tests__/dead-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,3 +763,106 @@ public class App {
expect(listed(report, 'DemoComponent::ngOnInit')).toBe(false);
});
});

describe('a declaration that merges into a type outside the index', () => {
let root: string;
let graph: CodeGraph;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deadcode-ambient-'));
// ghostfolio's chart.registry.ts: chart.js reads both interfaces through
// the types they extend, and nothing in the repository names either.
write(
root,
'src/chart.registry.ts',
`import { Chart, Tooltip, type ChartType } from 'chart.js';

interface VerticalHoverLinePluginOptions {
color?: string;
}

declare module 'chart.js' {
interface PluginOptionsByType<TType extends ChartType> {
verticalHoverLine: TType extends 'line' ? VerticalHoverLinePluginOptions : never;
}
interface TooltipPositionerMap {
top: (items: unknown[]) => { x: number; y: number };
}
}

export function registerChartConfiguration(): void {
Chart.register(Tooltip);
}

function unusedChartHelper(): void {}
`
);
// angular-realworld's app.config.ts, with a Node.js augmentation beside it.
write(
root,
'src/app.config.ts',
`declare global {
interface Window {
__conduit_debug__?: { token(): string | null };
}
namespace NodeJS {
interface ProcessEnv {
API_URL?: string;
}
}
}

// In a module file this namespace is the file's own: it merges with nothing.
declare namespace Settings {
interface NotMergedAnywhere {
retries: number;
}
}

export const appConfig = { providers: [] };
`
);
write(
root,
'src/main.ts',
`import { registerChartConfiguration } from './chart.registry';
import { appConfig } from './app.config';

registerChartConfiguration();
export const config = appConfig;
`
);
graph = CodeGraph.initSync(root, { config: { include: ['src/**/*.ts'], exclude: [] } });
await graph.indexAll();
}, 60_000);

afterAll(() => {
graph?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

const merged = ['PluginOptionsByType', 'TooltipPositionerMap', 'Window', 'ProcessEnv'];

it('does not list what a declare module or declare global block declares', () => {
for (const readSource of [undefined, null] as const) {
const report = buildDeadCodeReport(graph, readSource === null ? { readSource } : {});
for (const name of merged) expect(names(report)).not.toContain(name);
// TypeScript exports them without the keyword, so the exported rule
// takes them, and counts them.
expect(report.excluded.exported).toBe(merged.length);
}
});

it('lists them with the outside-reach caveat when exported symbols are asked for', () => {
const report = buildDeadCodeReport(graph, { includeExported: true });
for (const name of merged) {
expect(report.entries.find((entry) => entry.node.name === name)?.exported).toBe(true);
}
});

it('still lists what nothing reaches beside them', () => {
const report = buildDeadCodeReport(graph);
expect(names(report)).toContain('unusedChartHelper');
expect(names(report)).toContain('NotMergedAnywhere');
});
});
208 changes: 208 additions & 0 deletions __tests__/ts-ambient-exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* 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'))
);
}
});
});
47 changes: 45 additions & 2 deletions codegraph-kernel/src/tsjs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,8 @@ impl<'t> Walker<'t> {
None
}

/// isExported: walk the parent chain for an export_statement.
/// isExported: walk the parent chain for an export_statement, then ask
/// whether a `declare module '…'` / `declare global` body exports it.
fn is_exported(&self, node: Node) -> bool {
let mut cur = node.parent();
while let Some(p) = cur {
Expand All @@ -1039,7 +1040,7 @@ impl<'t> Walker<'t> {
}
cur = p.parent();
}
false
is_ambient_export(node)
}

fn has_keyword_child(&self, node: Node, kw: &str) -> bool {
Expand Down Expand Up @@ -1136,6 +1137,48 @@ fn body_of(node: Node) -> Option<Node> {
resolve_field_body(node).or_else(|| node.child_by_field_name("body"))
}

/// isAmbientExport (languages/typescript.ts): declared in a `declare module
/// 'x' { … }` or `declare global { … }` body, which TypeScript exports without
/// an `export` keyword unless the body holds an export declaration of its own.
fn is_ambient_export(node: Node) -> bool {
let mut cur = node.parent();
while let Some(p) = cur {
if p.kind() == "statement_block" && has_export_declaration(p) {
return false;
}
if p.kind() == "ambient_declaration" {
// `declare global` is the keyword and a bare block, with no field names.
for i in 0..p.child_count() {
if p.child(i).is_some_and(|c| c.kind() == "global") {
return true;
}
}
if let Some(declared) = p.named_child(0) {
if declared.kind() == "module"
&& declared.child_by_field_name("name").is_some_and(|n| n.kind() == "string")
{
return true;
}
}
}
cur = p.parent();
}
false
}

/// hasExportDeclaration: a statement that exports by name or by assignment
/// (`export {…}`, `export =`, `export default x`), not by declaring.
fn has_export_declaration(block: Node) -> bool {
for i in 0..block.named_child_count() {
if let Some(statement) = block.named_child(i) {
if statement.kind() == "export_statement" && statement.child_by_field_name("declaration").is_none() {
return true;
}
}
}
false
}

fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
match s {
Some(s) => arena.put(s),
Expand Down
2 changes: 1 addition & 1 deletion docs/design/codegraph-ui-design-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ entry-points panel.

**The caveat is part of the screen, not a note on it.** A persistent 11.5px `--ink-3` line sits above the rows, between two
hairline rules, and never collapses or dismisses: *"No static reference in the index — dynamic use is possible."* Under the list,
every reason a candidate was left off is printed with its count ("1 677 in test files", "378 exported, or declared in a header",
every reason a candidate was left off is printed with its count ("1 677 in test files", "378 exported, declared in a header, or merged into a library or global type",
"40 overriding a member declared further up"), preceded by the scale — *"2 494 symbols in this index carry no incoming reference
at all; 2 474 of them were left off this list."* Twenty rows drawn from twenty candidates and twenty drawn from two and a half
thousand are different screens and only that sentence tells them apart.
Expand Down
Loading