import { existsSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import ts from 'typescript'; const projectRoot = process.cwd(); const sourceRoot = path.join(projectRoot, 'src'); const toPosix = (value) => value.split(path.sep).join('/'); const listTypeScriptFiles = (directory) => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { return listTypeScriptFiles(entryPath); } return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') ? [entryPath] : []; }); const files = listTypeScriptFiles(sourceRoot); const fileSet = new Set(files.map((file) => path.resolve(file))); const resolveModule = (fromFile, specifier) => { if (!specifier.startsWith('.')) { return null; } const base = path.resolve(path.dirname(fromFile), specifier); const candidates = [ `${base}.ts`, path.join(base, 'index.ts'), base.endsWith('.ts') ? base : null, ].filter(Boolean); return ( candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null ); }; const exportKey = (file, name) => `${path.resolve(file)}:${name}`; const isExported = (node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ); const isDefaultExported = (node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some( (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword ); const exportedDeclarations = new Map(); const usedExports = new Set(); const wildcardUsedFiles = new Set(); const markUsed = (fromFile, name) => { usedExports.add(exportKey(fromFile, name)); }; const collectImportUsage = (file, sourceFile) => { sourceFile.forEachChild((node) => { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { const resolved = resolveModule(file, node.moduleSpecifier.text); if (!resolved || !node.importClause) { return; } if (node.importClause.name) { markUsed(resolved, 'default'); } const namedBindings = node.importClause.namedBindings; if (namedBindings && ts.isNamedImports(namedBindings)) { namedBindings.elements.forEach((element) => { markUsed(resolved, (element.propertyName ?? element.name).text); }); } else if (namedBindings && ts.isNamespaceImport(namedBindings)) { wildcardUsedFiles.add(path.resolve(resolved)); } return; } if ( ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) ) { const resolved = resolveModule(file, node.moduleSpecifier.text); if (!resolved) { return; } if (!node.exportClause) { wildcardUsedFiles.add(path.resolve(resolved)); return; } if (ts.isNamedExports(node.exportClause)) { node.exportClause.elements.forEach((element) => { markUsed(resolved, (element.propertyName ?? element.name).text); }); } } }); }; const collectExportedDeclarations = (file, sourceFile) => { if (file.endsWith('.test.ts')) { return; } sourceFile.forEachChild((node) => { if (ts.isVariableStatement(node) && isExported(node)) { node.declarationList.declarations.forEach((declaration) => { if (ts.isIdentifier(declaration.name)) { exportedDeclarations.set(exportKey(file, declaration.name.text), { file, name: declaration.name.text, }); } }); return; } if ( (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node)) && isExported(node) ) { if (isDefaultExported(node)) { exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' }); return; } if (node.name) { exportedDeclarations.set(exportKey(file, node.name.text), { file, name: node.name.text, }); } return; } if ( ts.isExportDeclaration(node) && !node.moduleSpecifier && node.exportClause && ts.isNamedExports(node.exportClause) ) { node.exportClause.elements.forEach((element) => { exportedDeclarations.set(exportKey(file, element.name.text), { file, name: element.name.text, }); }); } }); }; const parsedFiles = files.map((file) => ({ file, sourceFile: ts.createSourceFile( file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ), })); parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile)); parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile) ); const unusedExports = Array.from(exportedDeclarations.entries()) .filter( ([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file) ) .map(([, declaration]) => declaration) .sort((left, right) => `${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`) ); if (unusedExports.length > 0) { console.error('Unused exported declarations found:'); unusedExports.forEach(({ file, name }) => { console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`); }); process.exitCode = 1; }