unused-exports-check
Version:
Generic scanner to detect unused TypeScript exports (API + CLI)
257 lines (256 loc) • 12.7 kB
JavaScript
"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;
}