diff --git a/packages/knip/src/ProjectPrincipal.ts b/packages/knip/src/ProjectPrincipal.ts index 2cecd84c0..ee20063ae 100644 --- a/packages/knip/src/ProjectPrincipal.ts +++ b/packages/knip/src/ProjectPrincipal.ts @@ -2,7 +2,7 @@ import ts from 'typescript'; import { CacheConsultant } from './CacheConsultant.js'; import { getCompilerExtensions } from './compilers/index.js'; import type { AsyncCompilers, SyncCompilers } from './compilers/types.js'; -import { ANONYMOUS, DEFAULT_EXTENSIONS, FOREIGN_FILE_EXTENSIONS, PUBLIC_TAG } from './constants.js'; +import { ANONYMOUS, DEFAULT_EXTENSIONS, FOREIGN_FILE_EXTENSIONS } from './constants.js'; import type { GetImportsAndExportsOptions } from './types/config.js'; import type { DependencyGraph, Export, ExportMember, FileNode, UnresolvedImport } from './types/dependency-graph.js'; import type { PrincipalOptions } from './types/project.js'; @@ -357,6 +357,17 @@ export class ProjectPrincipal { }); } + public findUnusedMember(filePath: string, member: ExportMember) { + if (!this.findReferences) { + const languageService = ts.createLanguageService(this.backend.languageServiceHost, ts.createDocumentRegistry()); + this.findReferences = timerify(languageService.findReferences); + } + + const referencedSymbols = this.findReferences?.(filePath, member.pos) ?? []; + const refs = referencedSymbols.flatMap(refs => refs.references).filter(ref => !ref.isDefinition); + return refs.length === 0; + } + public findUnusedMembers(filePath: string, members: ExportMember[]) { if (!this.findReferences) { const languageService = ts.createLanguageService(this.backend.languageServiceHost, ts.createDocumentRegistry()); diff --git a/packages/knip/src/index.ts b/packages/knip/src/index.ts index 4a5c01174..e1a48a2d5 100644 --- a/packages/knip/src/index.ts +++ b/packages/knip/src/index.ts @@ -525,11 +525,13 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { if (members.length === 0 || !principal.shouldAnalyzeTypeMembers(filePath, exportedItem)) continue; - const unused: string[] = []; - const isParentUnused = (id: string) => id.includes('.') && unused.some(p => id.startsWith(`${p}.`)); + const unusedParents = new Set(); - for (const member of principal.findUnusedMembers(filePath, members)) { - if (isParentUnused(member.identifier)) continue; + for (const member of members) { + if (member.identifier.includes('.')) { + const parentId = member.identifier.split('.')[0]; + if (unusedParents.has(parentId)) continue; + } const id = `${identifier}.${member.identifier}`; const { isReferenced: isMemberReferenced } = isIdentifierReferenced(filePath, id, true); @@ -538,19 +540,22 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { if (!isMemberReferenced && !(!isReferenced && !isUnignoreMembers)) { if (isIgnored) continue; - const isIssueAdded = collector.addIssue({ - type: 'typeMembers', - filePath, - workspace: workspace.name, - symbol: member.identifier, - parentSymbol: exportedItem.identifier, - pos: member.pos, - line: member.line, - col: member.col, - }); - - unused.push(member.identifier); - if (isFix && isIssueAdded && member.fix) fixer.addUnusedTypeNode(filePath, [member.fix]); + if (principal.findUnusedMember(filePath, member)) { + const isIssueAdded = collector.addIssue({ + type: 'typeMembers', + filePath, + workspace: workspace.name, + symbol: member.identifier, + parentSymbol: exportedItem.identifier, + pos: member.pos, + line: member.line, + col: member.col, + }); + + if (!member.identifier.includes('.')) unusedParents.add(member.identifier); + + if (isFix && isIssueAdded && member.fix) fixer.addUnusedTypeNode(filePath, [member.fix]); + } } else if (isIgnored) { const identifier = `${exportedItem.identifier}.${member.identifier}`; for (const tagName of exportedItem.jsDocTags) {