Skip to content

Commit 7901abd

Browse files
committed
Don't report types used only in module augmentations as unused (resolve #1843)
Types referenced inside `declare module './x'` resolve to that module's exports via TS augmentation scope without an import statement; register them as type imports so those exports aren't flagged as unused.
1 parent 6f090f9 commit 7901abd

7 files changed

Lines changed: 103 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
declare module './events.js' {
2+
type EntityWithMeta = BaseEntity & { meta: string };
3+
4+
export interface EventBusEvents extends EventEnvelope {
5+
'entity:created': EntityWithMeta;
6+
'entity:updated': BaseEntity & { revision: number };
7+
}
8+
}
9+
10+
export {};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface BaseEntity {
2+
id: string;
3+
}
4+
5+
export interface EventEnvelope {
6+
at: number;
7+
}
8+
9+
export interface EventBusEvents {}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import './events.augmentation.js';
2+
import type { EventBusEvents } from './events.js';
3+
4+
export const handled: (keyof EventBusEvents)[] = [];
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "@fixtures/declare-module-augmentation",
3+
"type": "module"
4+
}

packages/knip/src/typescript/ast-nodes.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type TSEnumDeclaration,
55
type TSEnumMember,
66
type TSModuleDeclaration,
7+
visitorKeys,
78
} from 'oxc-parser';
89
import { DEFAULT_EXTENSIONS, FIX_FLAGS, SYMBOL_TYPE } from '../constants.ts';
910
import { extname } from '../util/path.ts';
@@ -158,6 +159,46 @@ export function extractNamespaceMembers(
158159
return members;
159160
}
160161

162+
export const collectAugmentationRefs = (node: TSModuleDeclaration): string[] => {
163+
if (!node.body || node.body.type !== 'TSModuleBlock') return [];
164+
const body = node.body.body;
165+
166+
const declared = new Set<string>();
167+
for (const stmt of body) {
168+
const decl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration ? stmt.declaration : stmt;
169+
if ('id' in decl && decl.id?.type === 'Identifier') declared.add(decl.id.name);
170+
else if (decl.type === 'VariableDeclaration')
171+
for (const d of decl.declarations) if (d.id.type === 'Identifier') declared.add(d.id.name);
172+
}
173+
174+
const refs: string[] = [];
175+
const seen = new Set<string>();
176+
const add = (ref: any) => {
177+
if (ref?.type === 'Identifier' && !declared.has(ref.name) && !seen.has(ref.name)) {
178+
seen.add(ref.name);
179+
refs.push(ref.name);
180+
}
181+
};
182+
const visit = (n: any) => {
183+
const type = n?.type;
184+
if (!type) return;
185+
if (type === 'TSTypeReference') add(n.typeName);
186+
else if (type === 'TSInterfaceHeritage') add(n.expression);
187+
const keys = visitorKeys[type];
188+
if (!keys) return;
189+
for (const key of keys) {
190+
const val = n[key];
191+
if (!val) continue;
192+
if (Array.isArray(val)) {
193+
for (const item of val) if (item) visit(item);
194+
} else visit(val);
195+
}
196+
};
197+
for (const stmt of body) visit(stmt);
198+
199+
return refs;
200+
};
201+
161202
export function extractEnumMembers(
162203
decl: TSEnumDeclaration,
163204
options: GetImportsAndExportsOptions,

packages/knip/src/typescript/visitors/walk.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import type { Export, ExportMember, ImportMap, ImportMaps } from '../../types/mo
1818
import { addValue } from '../../util/module-graph.ts';
1919
import { isInNodeModules } from '../../util/path.ts';
2020
import { timerify } from '../../util/Performance.ts';
21-
import { getLineAndCol, getStringValue, isStringLiteral, type ResolveModule } from '../ast-nodes.ts';
21+
import {
22+
collectAugmentationRefs,
23+
getLineAndCol,
24+
getStringValue,
25+
isStringLiteral,
26+
type ResolveModule,
27+
} from '../ast-nodes.ts';
2228
import { EMPTY_TAGS } from './jsdoc.ts';
2329
import { handleCallExpression, handleNewExpression, trackCustomElementRegistry } from './calls.ts';
2430
import {
@@ -327,6 +333,12 @@ const coreVisitorObject: VisitorObject = {
327333
},
328334
TSModuleDeclaration(node) {
329335
state.nsRanges.push([node.start, node.end]);
336+
if (node.kind !== 'global' && isStringLiteral(node.id)) {
337+
const specifier = getStringValue(node.id)!;
338+
if (specifier.startsWith('.'))
339+
for (const name of collectAugmentationRefs(node))
340+
state.addImport(specifier, name, undefined, undefined, node.id.start, IMPORT_FLAGS.TYPE_ONLY);
341+
}
330342
},
331343
ClassDeclaration(node) {
332344
state.classNameStack.push(node.id?.name ?? '');
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import { main } from '../../src/index.ts';
4+
import baseCounters from '../helpers/baseCounters.ts';
5+
import { createOptions } from '../helpers/create-options.ts';
6+
import { resolve } from '../helpers/resolve.ts';
7+
8+
const cwd = resolve('fixtures/types/declare-module-augmentation');
9+
10+
test('Type used only in a declare module augmentation is not reported unused (#1843)', async () => {
11+
const options = await createOptions({ cwd });
12+
const { issues, counters } = await main(options);
13+
14+
assert(!issues.types['events.ts']?.['BaseEntity']);
15+
assert(!issues.types['events.ts']?.['EventEnvelope']);
16+
17+
assert.deepEqual(counters, {
18+
...baseCounters,
19+
processed: 3,
20+
total: 3,
21+
});
22+
});

0 commit comments

Comments
 (0)