Skip to content

Commit 3a2c22b

Browse files
committed
Fix declaration export regression and document (resolve #1722)
1 parent ddcf7de commit 3a2c22b

10 files changed

Lines changed: 99 additions & 64 deletions

File tree

.agents/EXPORTS.md

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,70 @@
11
# Identifiers, Symbols & Matching
22

3-
How knip resolves "is this export used?" across an AST. Matching is name-based;
4-
the sections below cover the cases that need extra care.
3+
How knip resolves "is this export used?" across an AST. Matching is name-based.
54

65
## Shadow detection
76

8-
Name-based matching produces false negatives when a local binding shadows an
9-
exported name of the same spelling. `isShadowed(name, pos)` returns true if the
10-
reference at `pos` falls inside a scope that shadows `name`. Shadows are
11-
registered via:
7+
A local binding can shadow an exported name, producing false negatives.
8+
`isShadowed(name, pos)` returns true if the reference at `pos` falls inside a
9+
scope that shadows `name`. Shadows registered via:
1210

13-
- `_addShadow`: block-scoped variables and nested function declarations
14-
(uses current `scopeDepth` range from `BlockStatement`)
11+
- `_addShadow`: block-scoped variables and nested function declarations (uses
12+
current `scopeDepth` range from `BlockStatement`)
1513
- `_addParamShadows`: function/arrow/method parameters (uses function body
1614
range directly, since params are visited before the body's `BlockStatement`)
1715
- `CatchClause` handler: catch binding (uses catch body range)
1816
- `ForInStatement`/`ForOfStatement` handlers: loop variable (uses loop body
1917
range, since the `VariableDeclaration` fires before the body's `BlockStatement`)
2018

2119
`_collectBindingNames` recurses into destructuring patterns (ObjectPattern,
22-
ArrayPattern, AssignmentPattern, RestElement) to extract all bound Identifiers.
20+
ArrayPattern, AssignmentPattern, RestElement) to extract bound Identifiers.
2321

2422
## `ignoreExportsUsedInFile`
2523

2624
Opt-in config (default `false`).
2725

2826
**Default semantics match tsc/tsgo.** An export only referenced in its own file
29-
is reported as unused, because removing the `export` keyword leaves the program
30-
and `.d.ts` valid. Types are structurally inlined: a consumer importing
27+
is reported as unused; removing the `export` keyword leaves the program and
28+
`.d.ts` valid. Type aliases inline structurally: a consumer importing
3129
`UserInfo = { address: Address }` does not require `Address` to be exported.
32-
Same for `typeof X` references inside type aliases. So `Address` is correctly
33-
flagged. Opting `true` is a code-organization preference, not a correctness
34-
concern.
30+
Same for `typeof X` references inside type aliases. Opting `true` is a
31+
code-organization preference, not a correctness concern.
32+
33+
**Does not apply to types in value signatures.** Types referenced in exported
34+
value signatures (function params/returns, variable annotations, `typeof` of an
35+
exported value) stay alive unconditionally. `tsc --declaration` cannot inline
36+
an interface or class type into a `.d.ts` and errors TS4023 if the name is not
37+
exported. That path is intentionally not gated by `ignoreExportsUsedInFile`.
3538

3639
**With the config on.** `localRefsVisitorObject` populates `localRefs` during
3740
AST traversal. Exports present in `localRefs` get `hasRefsInFile = true`.
3841
`shouldCountRefs` gates eligible types. Computed member access
3942
(`obj[EXPORTED_KEY]`) is handled.
4043

4144
`analyze.ts` reads this via `isReferencedInUsedExport` for exports not directly
42-
imported: returns true only when a containing export has `hasRefsInFile` and is
43-
a type/interface (recursively checked). Alive-ness does not cascade through
44-
external imports; inner refs stay scoped to the in-file relationship.
45+
imported: returns true when a containing export has `hasRefsInFile` and is a
46+
type/interface (recursively checked). Alive-ness does not cascade through
47+
external imports; chains stay in-file.
48+
49+
## `referencedInExport` (chain refs)
50+
51+
Maps exported identifier to set of export names whose declarations reference
52+
it. Populated for type aliases, interfaces (body and `extends`), and the
53+
signature parts of exported values: variable type annotations, function/arrow
54+
expression params and return types, function/method declarations.
55+
Function/arrow bodies are skipped via `signatureOnly`.
56+
57+
`isReferencedInUsedExport` walks the chain, classifying each hop:
4558

46-
## `referencedInExport` (type-chain refs)
59+
- **Type→type hop** (containing export is `type`/`interface`/`enum`):
60+
propagates only when `ignoreExportsUsedInFile` is on. Otherwise the chain
61+
breaks here, since tsc structurally inlines the inner type and its export is
62+
removable.
63+
- **Type→value hop** (containing export is a function/class/variable):
64+
propagates unconditionally. Needed for `tsc --declaration`; removing the
65+
inner type's export would TS4023.
4766

48-
Maps exported identifier → set of export names whose type annotations reference
49-
it. Type-level only, not function signatures. Type→type chains are followed;
50-
type→function chains do not keep types alive. Interface `extends` clauses
51-
captured via `addRefInExport`. Feeds the recursive type/interface check in
52-
`isReferencedInUsedExport` above.
67+
Interface `extends` clauses captured via `addRefInExport`.
5368

5469
## Namespace/enum member `hasRefsInFile`
5570

@@ -75,14 +90,20 @@ If neither, reported as unused.
7590

7691
**Edge case:** when a namespace/enum has export-level `hasRefsInFile = true`
7792
but is NOT externally imported, `analyze.ts` skips the member check entirely.
78-
Unused members silently pass. By design (the export itself is considered
79-
"used").
93+
Unused members silently pass. By design: the export itself is considered "used".
8094

8195
## E2E
8296

83-
`packages/knip/test/e2e/fix-tsgo.test.ts` is the safety net for the resolution
84-
paths above. Each fixture builds clean under tsgo, has `knip --fix` run on it,
85-
then must build clean under tsgo again. A failing post-fix build means knip
86-
removed something tsc/tsgo still needs: a false positive in one of these
87-
mechanisms. The `e2e-lib-*` variants extend the round-trip to a consumer so
88-
type-visibility regressions also fail.
97+
`packages/knip/test/e2e/fix-tsgo.test.ts` covers the resolution paths above.
98+
Each fixture builds clean under tsgo, has `knip --fix` run on it, then must
99+
build clean under tsgo again. A failing post-fix build means knip removed
100+
something tsc/tsgo still needs: a false positive in one of these mechanisms.
101+
The `e2e-lib-*` variants extend the round-trip to a consumer with
102+
`declaration: true` so type-visibility regressions (TS4023 etc.) also fail.
103+
104+
When adding a fixture for a value-to-type-visibility scenario, do not also
105+
re-export the type explicitly from the public entry. That path masks
106+
type-chain bugs: the type stays alive via the re-export regardless of whether
107+
knip tracks the value's signature. Test the implicit case (type only
108+
referenced in a function signature, never named at the entry) so the chain is
109+
the only thing keeping the export alive.
Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,2 @@
11
Unused exports (1)
22
.flue/lib/github.ts: issueDetailsSchema, repoLabelSchema
3-
Unused exported types (6)
4-
packages/create-astro/test/test-utils.ts: Context
5-
packages/integrations/cloudflare/test/test-utils.ts: AstroInlineConfig
6-
packages/integrations/netlify/test/test-utils.ts: AstroInlineConfig
7-
packages/integrations/node/test/test-utils.ts: AstroInlineConfig
8-
packages/integrations/sitemap/test/test-utils.ts: AstroInlineConfig
9-
packages/integrations/vercel/test/test-utils.ts: VercelOutputConfig
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
import { pickFruit, type Fruit } from '@fixtures/e2e-lib-public-surface';
1+
import { pickFruit, fetchApi, type Fruit } from '@fixtures/e2e-lib-public-surface';
22
const x: Fruit = pickFruit('apple');
3+
const r = fetchApi();
34
x;
5+
r.status;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export type { Fruit } from './types.ts';
2-
export { pickFruit } from './types.ts';
2+
export { pickFruit, fetchApi } from './types.ts';

packages/knip/fixtures/e2e-lib-public-surface/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ export const pickFruit = (name: string): Fruit => ({ name, color: 'red' });
88
export const internalUnused = 1;
99

1010
export type DraftFruit = { name: string };
11+
12+
export interface ApiResult {
13+
readonly status: 'ok';
14+
}
15+
16+
export const fetchApi = (): ApiResult => ({ status: 'ok' });

packages/knip/src/graph/analyze.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,14 @@ export const analyze = async ({
5959
for (const containingExport of exportedItem.referencedIn) {
6060
const inExport = file.exports.get(containingExport);
6161
if (!inExport) continue;
62-
if (shouldCountRefs(ignoreExportsUsedInFile, inExport.type)) {
63-
if (inExport.hasRefsInFile) return true;
64-
if (explorer.isReferenced(filePath, containingExport, { includeEntryExports })[0]) return true;
62+
if (
63+
(inExport.type === 'type' || inExport.type === 'interface' || inExport.type === 'enum') &&
64+
!shouldCountRefs(ignoreExportsUsedInFile, inExport.type)
65+
) {
66+
continue;
6567
}
68+
if (inExport.hasRefsInFile) return true;
69+
if (explorer.isReferenced(filePath, containingExport, { includeEntryExports })[0]) return true;
6670
if (inExport.referencedIn) {
6771
const v = visited ?? new Set();
6872
if (!v.has(containingExport)) {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export function handleExportNamed(node: ExportNamedDeclaration, s: WalkState) {
140140

141141
s.addExport(name, SYMBOL_TYPE.UNKNOWN, declarator.id.start, [], fix, isReExport, jsDocTags);
142142

143+
if (declarator.id.typeAnnotation) s.collectRefsInType(declarator.id.typeAnnotation, name, true);
144+
if (declarator.init?.type === 'ArrowFunctionExpression' || declarator.init?.type === 'FunctionExpression') {
145+
s.collectRefsInType(declarator.init, name, true);
146+
}
147+
143148
if (!jsDocTags.has(ALIAS_TAG) && declarator.init?.type === 'Identifier') {
144149
const initName = declarator.init.name;
145150
const existingExport = s.exports.get(initName);
@@ -161,6 +166,7 @@ export function handleExportNamed(node: ExportNamedDeclaration, s: WalkState) {
161166
} else if ((decl.type === 'FunctionDeclaration' || decl.type === 'TSDeclareFunction') && decl.id) {
162167
const fix = s.getFix(exportStart, exportStart + 7);
163168
s.addExport(decl.id.name, SYMBOL_TYPE.FUNCTION, decl.id.start, [], fix, false, s.getJSDocTags(exportStart));
169+
s.collectRefsInType(decl, decl.id.name, true);
164170
} else if (decl.type === 'ClassDeclaration' && decl.id) {
165171
const fix = s.getFix(exportStart, exportStart + 7);
166172
s.addExport(decl.id.name, SYMBOL_TYPE.CLASS, decl.id.start, [], fix, false, s.getJSDocTags(exportStart));
@@ -245,6 +251,7 @@ export function handleExportDefault(node: ExportDefaultDeclaration, s: WalkState
245251
if (decl.type === 'FunctionDeclaration') {
246252
type = SYMBOL_TYPE.FUNCTION;
247253
pos = decl.id?.start ?? decl.start;
254+
s.collectRefsInType(decl, 'default', true);
248255
} else if (decl.type === 'ClassDeclaration') {
249256
type = SYMBOL_TYPE.CLASS;
250257
pos = decl.id?.start ?? decl.start;

packages/knip/test/ignore-exports-used-in-file-typeof-class.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ test('Ignore exports used in file for typeof function and class references', asy
1212
const { issues, counters } = await main(options);
1313

1414
assert(issues.exports['src/api.ts']['TreeLeaf']);
15-
assert(issues.exports['src/api.ts']['TreeNode']);
16-
assert(issues.exports['src/api.ts']['logger']);
1715

16+
assert(!issues.exports['src/api.ts']?.['TreeNode']);
17+
assert(!issues.exports['src/api.ts']?.['logger']);
1818
assert(!issues.exports['src/api.ts']?.['Leaf']);
1919
assert(!issues.exports['src/api.ts']?.['Node']);
2020

2121
assert.deepEqual(counters, {
2222
...baseCounters,
23-
exports: 3,
23+
exports: 1,
2424
processed: 2,
2525
total: 2,
2626
});

packages/knip/test/type-in-value-export.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,10 @@ test('Find unused types in value exports', async () => {
1212
const { issues, counters } = await main(options);
1313

1414
assert(issues.types['src/api.ts']['GetPointsResponse']);
15-
assert(issues.types['src/api.ts']['GetPoints']);
16-
assert(issues.types['src/api.ts']['GetPointsParams']);
1715

1816
assert.deepEqual(counters, {
1917
...baseCounters,
20-
types: 3,
18+
types: 1,
2119
processed: 2,
2220
total: 2,
2321
});

packages/knip/test/type-visibility.test.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,21 @@ import { resolve } from './helpers/resolve.ts';
77

88
const cwd = resolve('fixtures/type-visibility');
99

10-
test('Report types only externally unused (per tsc: structural inlining does not keep inner exports alive)', async () => {
10+
test('Keep types referenced in exported value signatures alive (declaration emit / TS4023)', async () => {
1111
const options = await createOptions({ cwd });
1212
const { issues, counters } = await main(options);
1313

14-
// Type → function: reported (nobody imports the type)
15-
assert(issues.types['src/lib.ts']['ParamConfig']);
16-
assert(issues.types['src/lib.ts']['ResponseData']);
14+
// Type → exported value signature: required for .d.ts emit
15+
assert(!issues.types['src/lib.ts']?.['ParamConfig']);
16+
assert(!issues.types['src/lib.ts']?.['ResponseData']);
17+
assert(!issues.types['src/lib.ts']?.['LogLevel']);
18+
assert(!issues.exports['src/lib.ts']?.['Connection']);
19+
assert(!issues.exports['src/lib.ts']?.['defaultHandler']);
20+
21+
// Used only in function body (not signature): still flagged
1722
assert(issues.types['src/lib.ts']['InternalState']);
18-
assert(issues.types['src/lib.ts']['LogLevel']);
19-
assert(issues.exports['src/lib.ts']['Connection']);
20-
assert(issues.exports['src/lib.ts']['defaultHandler']);
2123

22-
// Type → type (imported): inner type is structurally inlined flagged
24+
// Type → type (structurally inlined): flagged
2325
assert(issues.types['src/lib.ts']['SuccessResult']);
2426
assert(issues.types['src/lib.ts']['ErrorResult']);
2527
assert(issues.types['src/lib.ts']['BaseEntity']);
@@ -28,19 +30,21 @@ test('Report types only externally unused (per tsc: structural inlining does not
2830
// Directly imported: kept
2931
assert(!issues.types['src/lib.ts']?.['DirectlyUsed']);
3032

31-
// Type → type → function chain: reported (chain ends at function)
33+
// Chain: FilterRule → FilterSet (type) → applyFilters (function).
34+
// FilterSet is in a value signature → kept; FilterRule structurally inlined into FilterSet → flagged.
35+
assert(!issues.types['src/lib.ts']?.['FilterSet']);
3236
assert(issues.types['src/lib.ts']['FilterRule']);
33-
assert(issues.types['src/lib.ts']['FilterSet']);
3437

35-
// Unused
36-
assert(issues.types['src/lib.ts']['CompletelyUnused']);
37-
assert(issues.types['src/lib.ts']['SharedConfig']);
38+
// SharedConfig used in both function param and unused type alias → kept (via function path)
39+
assert(!issues.types['src/lib.ts']?.['SharedConfig']);
3840
assert(issues.types['src/lib.ts']['AppConfig']);
3941

42+
// Genuinely unused
43+
assert(issues.types['src/lib.ts']['CompletelyUnused']);
44+
4045
assert.deepEqual(counters, {
4146
...baseCounters,
42-
exports: 2,
43-
types: 13,
47+
types: 8,
4448
processed: 2,
4549
total: 2,
4650
});

0 commit comments

Comments
 (0)