UNPKG

unused-exports-check

Version:

Generic scanner to detect unused TypeScript exports (API + CLI)

257 lines (256 loc) 12.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.scan = scan; exports.assertNone = assertNone; const typescript_1 = __importDefault(require("typescript")); const node_path_1 = __importDefault(require("node:path")); const node_fs_1 = __importDefault(require("node:fs")); const glob_1 = require("glob"); const DEFAULT_GLOBS = ['src/**/*.ts', 'src/**/*.tsx']; const DEFAULT_IGNORES = ['**/node_modules/**', '**/dist/**', '**/*.d.ts', 'packages/unused-exports/**']; const FALLBACK_ALIASES = { '@api/*': ['packages/api/*'], '@ui/*': ['packages/ui/*'], '@features/*': ['packages/ui/features/*'], '@clients/*': ['packages/common/src/clients/*'], '@common/*': ['packages/common/src/*'], '@models/*': ['packages/ui/src/models/*'], '@pages/*': ['packages/ui/src/pages/*'], '@setup/*': ['packages/ui/src/setup/*'], '@steps/*': ['packages/ui/src/steps/*'], '@common-utils/*': ['packages/common/src/utils/*'], '@utils/*': ['packages/common/src/utils/*'], '@services/*': ['packages/common/src/services/*'], '@helpers/*': ['packages/ui/src/helpers/*'], '@common-helpers/*': ['packages/common/src/helpers/*'], '@api-helpers/*': ['packages/api/src/helpers/*'], '@api-fixtures/*': ['packages/api/src/fixtures/*'], '@api-config/*': ['packages/api/src/config/*'], '@api-tests/*': ['packages/api/src/tests/*'] }; async function scan(options = {}) { const root = node_path_1.default.resolve(options.root || process.cwd()); const globs = options.globs && options.globs.length ? options.globs : DEFAULT_GLOBS; const ignore = DEFAULT_IGNORES.concat(options.ignore || []); const files = await (0, glob_1.glob)(globs, { cwd: root, ignore, absolute: true, nodir: true }); const { baseUrl, paths } = loadTsConfigPaths(options.tsconfig || node_path_1.default.join(root, 'tsconfig.json')); const aliasMappings = { // Always include the legacy fallbacks first, then let tsconfig/overrides win ...FALLBACK_ALIASES, ...(paths || {}), ...(options.aliases || {}) }; const program = typescript_1.default.createProgram(files, { jsx: typescript_1.default.JsxEmit.React, target: typescript_1.default.ScriptTarget.ES2022, module: typescript_1.default.ModuleKind.CommonJS, moduleResolution: typescript_1.default.ModuleResolutionKind.NodeJs, skipLibCheck: true, esModuleInterop: true, resolveJsonModule: true, baseUrl: baseUrl || root, paths: aliasMappings }); const exports = []; const imports = new Map(); // re-exports map: barrel file -> [{ exportedName, targetFile }, or wildcard to targetFile] const reExports = new Map(); const sourceFiles = program.getSourceFiles(); for (let i = 0; i < sourceFiles.length; i += 1) { const sf = sourceFiles[i]; if (sf.isDeclarationFile) continue; if (files.indexOf(sf.fileName) === -1) continue; // Per-file disable pragma: // unused-exports-check: disable const text = sf.getFullText(); if (/unused-exports-check:\s*disable/.test(text)) continue; typescript_1.default.forEachChild(sf, (node) => { if (typescript_1.default.isImportDeclaration(node)) { const moduleSpecifier = node.moduleSpecifier.text; const clause = node.importClause; const resolved = resolveModule(sf.fileName, moduleSpecifier, root, { baseUrl, paths: aliasMappings }); if (!resolved) return; if (!imports.has(resolved)) imports.set(resolved, new Set()); const set = imports.get(resolved); if (clause?.name) set.add('default'); if (clause?.namedBindings) { if (typescript_1.default.isNamespaceImport(clause.namedBindings)) set.add('*'); else if (typescript_1.default.isNamedImports(clause.namedBindings)) clause.namedBindings.elements.forEach((el) => set.add(el.propertyName ? el.propertyName.text : el.name.text)); } return; } if (typescript_1.default.isExportAssignment(node)) { exports.push({ filePath: sf.fileName, name: 'default', kind: 'unknown', isDefault: true }); return; } if (typescript_1.default.isExportDeclaration(node)) { const ms = node.moduleSpecifier ? node.moduleSpecifier.text : undefined; if (!node.exportClause) { if (ms) { const resolved = resolveModule(sf.fileName, ms, root, { baseUrl, paths: aliasMappings }); if (!resolved) return; if (!imports.has(resolved)) imports.set(resolved, new Set()); imports.get(resolved).add('*'); const arr = reExports.get(sf.fileName) || []; arr.push({ exportedName: '*', targetFile: resolved }); reExports.set(sf.fileName, arr); } return; } if (typescript_1.default.isNamedExports(node.exportClause)) { node.exportClause.elements.forEach((el) => { const local = el.propertyName ? el.propertyName.text : el.name.text; const exported = el.name.text; if (ms) { const resolved = resolveModule(sf.fileName, ms, root, { baseUrl, paths: aliasMappings }); if (!resolved) return; if (!imports.has(resolved)) imports.set(resolved, new Set()); imports.get(resolved).add(local === 'default' ? 'default' : local); const arr = reExports.get(sf.fileName) || []; arr.push({ exportedName: exported, targetFile: resolved }); reExports.set(sf.fileName, arr); } else { const isDefault = exported === 'default'; exports.push({ filePath: sf.fileName, name: isDefault ? 'default' : exported, kind: 'unknown', isDefault }); } }); } return; } if (typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isClassDeclaration(node) || typescript_1.default.isVariableStatement(node) || typescript_1.default.isEnumDeclaration(node)) { const mods = typescript_1.default.canHaveModifiers(node) ? typescript_1.default.getModifiers(node) : undefined; const isExport = !!mods && mods.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword); const isDefault = !!mods && mods.some((m) => m.kind === typescript_1.default.SyntaxKind.DefaultKeyword); if (!isExport) return; if (typescript_1.default.isVariableStatement(node)) { node.declarationList.declarations.forEach((decl) => { if (typescript_1.default.isIdentifier(decl.name)) exports.push({ filePath: sf.fileName, name: decl.name.text, kind: 'variable', isDefault: false }); }); return; } const name = node.name ? node.name.text : undefined; const kind = typescript_1.default.isFunctionDeclaration(node) ? 'function' : typescript_1.default.isClassDeclaration(node) ? 'class' : typescript_1.default.isEnumDeclaration(node) ? 'enum' : 'unknown'; if (isDefault) exports.push({ filePath: sf.fileName, name: 'default', kind, isDefault: true }); else if (name) exports.push({ filePath: sf.fileName, name, kind, isDefault: false }); } }); } // Propagate usage through barrel files: if a symbol is re-exported and the barrel is used, mark target as used const transitivelyUsed = new Map(); // targetFile -> names const markUsed = (file, name) => { if (!transitivelyUsed.has(file)) transitivelyUsed.set(file, new Set()); const set = transitivelyUsed.get(file); set.add(name); // if this file re-exports, propagate const outs = reExports.get(file) || []; for (const out of outs) { if (name === '*' || out.exportedName === name || name === 'default' && out.exportedName === 'default') { markUsed(out.targetFile, name === '*' ? '*' : out.exportedName); } } }; // seed with direct imports for (const [file, names] of imports.entries()) { for (const n of names.values()) markUsed(file, n); } const unused = []; for (const e of exports) { const direct = imports.get(e.filePath); const trans = transitivelyUsed.get(e.filePath); const hasStar = (direct && direct.has('*')) || (trans && trans.has('*')); const hasName = (direct && direct.has(e.name)) || (trans && trans.has(e.name)); const hasDefault = (direct && direct.has('default')) || (trans && trans.has('default')); const isUsed = hasStar || (e.name === 'default' ? hasDefault : hasName); if (!isUsed) unused.push({ filePath: e.filePath, exportName: e.name, kind: e.kind }); } return unused; } function assertNone(unused, message = 'Unused exports found') { if (unused.length > 0) { const lines = unused.map((u) => `${u.filePath}: ${u.exportName} [${u.kind}]`).join('\n'); throw new Error(`${message}\n${lines}`); } } function loadTsConfigPaths(tsconfigPath) { if (!node_fs_1.default.existsSync(tsconfigPath)) return {}; try { // Resolve extends chain (merge baseUrl/paths) const resolveConfig = (p, acc) => { if (!node_fs_1.default.existsSync(p)) return acc; const json = JSON.parse(node_fs_1.default.readFileSync(p, 'utf-8')); const co = json.compilerOptions || {}; const mergedPaths = { ...(acc.paths || {}), ...(co.paths || {}) }; const merged = { baseUrl: co.baseUrl || acc.baseUrl, paths: mergedPaths }; if (json.extends) { const base = node_path_1.default.resolve(node_path_1.default.dirname(p), json.extends.endsWith('.json') ? json.extends : `${json.extends}.json`); return resolveConfig(base, merged); } return merged; }; return resolveConfig(tsconfigPath, {}); } catch { return {}; } } function resolveModule(fromFile, spec, root, cfg) { if (spec.startsWith('.')) { const base = node_path_1.default.resolve(node_path_1.default.dirname(fromFile), spec); const cands = [`${base}.ts`, `${base}.tsx`, node_path_1.default.join(base, 'index.ts'), node_path_1.default.join(base, 'index.tsx')]; for (const c of cands) { if (typescript_1.default.sys.fileExists(c)) return c; } return undefined; } const paths = cfg.paths || {}; const entries = Object.entries(paths); for (const [alias, targets] of entries) { if (!alias.endsWith('/*')) continue; const prefix = alias.slice(0, -2); if (!spec.startsWith(prefix)) continue; const sub = spec.slice(prefix.length); for (const t of targets) { const base = cfg.baseUrl ? node_path_1.default.resolve(root, cfg.baseUrl) : root; const pattern = node_path_1.default.resolve(base, t.replace('*', sub)); const cands = [`${pattern}.ts`, `${pattern}.tsx`, node_path_1.default.join(pattern, 'index.ts'), node_path_1.default.join(pattern, 'index.tsx')]; for (const c of cands) { if (typescript_1.default.sys.fileExists(c)) return c; } } } return undefined; }