packmate
Version:
Your smart and friendly interactive assistant for dependency updates, security advisories, and cleanup.
269 lines (227 loc) • 7.7 kB
JavaScript
import fs from 'fs-extra';
import { globby } from 'globby';
import os from 'os';
import path from 'path';
import precinct from 'precinct';
import process from 'process';
import pMap from 'p-map';
import { loadConfig, shouldIgnorePackage } from './config-loader.js';
// package.json을 반복해서 읽지 않도록 캐시
let cachedPkgJson = null;
let cachedPkgJsonPath = null;
function getCustomIgnoreList(pkgJson) {
// 예시: package.json의 "packmate.ignoreUnused" 필드 사용
return pkgJson?.packmate?.ignoreUnused || [];
}
/**
* 동적 import 패턴 감지
* import('module-name'), require(variable) 등
*/
function detectDynamicImports(content) {
const dynamicImports = new Set();
// import('module-name') 패턴
const importRegex = /import\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
dynamicImports.add(match[1]);
}
// require 변수 패턴에서 문자열 리터럴 추출 시도
const requireVarRegex = /require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
while ((match = requireVarRegex.exec(content)) !== null) {
dynamicImports.add(match[1]);
}
return Array.from(dynamicImports);
}
/**
* 조건부 require 감지
* if/try-catch 안의 require 등
*/
function detectConditionalRequires(content) {
const conditionalRequires = new Set();
// try-catch 블록 내 require
const tryRegex = /try\s*{[\s\S]*?require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)[\s\S]*?}\s*catch/g;
let match;
while ((match = tryRegex.exec(content)) !== null) {
conditionalRequires.add(match[1]);
}
// if 문 안의 require (단순 패턴)
const ifRegex = /if\s*\([^)]*\)\s*{[\s\S]*?require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
while ((match = ifRegex.exec(content)) !== null) {
conditionalRequires.add(match[1]);
}
return Array.from(conditionalRequires);
}
/**
* 패키지명 정규화 (스코프 패키지 처리)
*/
function normalizePackageName(dep) {
if (!dep || dep.startsWith('.') || path.isAbsolute(dep)) return null;
// @scope/package/subpath → @scope/package
if (dep.startsWith('@') && dep.includes('/')) {
const parts = dep.split('/');
return parts.slice(0, 2).join('/');
}
// package/subpath → package
return dep.split('/')[0];
}
/**
* 캐시된 package.json을 가져오거나 디스크에서 읽기
*/
async function getPkgJson() {
const pkgPath = path.resolve(process.cwd(), 'package.json');
if (cachedPkgJson && cachedPkgJsonPath === pkgPath) {
return cachedPkgJson;
}
cachedPkgJson = await fs.readJSON(pkgPath);
cachedPkgJsonPath = pkgPath;
return cachedPkgJson;
}
export async function runUnusedCheck({ withUsedList = false } = {}) {
const config = loadConfig();
const pkgJson = await getPkgJson();
const dependencies = Object.keys(pkgJson.dependencies || {});
const devDependencies = Object.keys(pkgJson.devDependencies || {});
const declared = [...dependencies, ...devDependencies];
const used = new Set();
const dynamicUsed = new Set(); // 동적 import로 사용된 것들
const conditionalUsed = new Set(); // 조건부 require로 사용된 것들
const extToType = {
'.js': 'es6',
'.jsx': 'es6',
'.ts': 'ts',
'.tsx': 'tsx',
'.mjs': 'es6',
'.cjs': 'commonjs',
};
const ignorePaths = config.detection?.ignorePaths || [];
const files = await globby([
'**/*.{js,ts,jsx,tsx,mjs,cjs}',
...ignorePaths.map((pattern) => `!${pattern}`),
]);
// 성능 향상을 위해 파일을 병렬로 처리
const concurrency = Math.max(os.cpus().length, 4);
const processFile = async (file) => {
const ext = path.extname(file);
const type = extToType[ext] || 'es6';
const content = await fs.readFile(file, 'utf8');
const fileDeps = new Set();
const fileDynamicImports = new Set();
const fileConditionalReqs = new Set();
// 1. Precinct를 통한 정적 분석
try {
const deps = precinct(content, { type });
deps.forEach((dep) => {
const pkgName = normalizePackageName(dep);
if (pkgName) fileDeps.add(pkgName);
});
} catch (e) {
if (config.ui?.verbose) {
console.warn(`precinct parse failed in "${file}": ${e.message}`);
}
}
// 2. 동적 import 감지
if (config.detection?.dynamicImport) {
const dynamicImports = detectDynamicImports(content);
dynamicImports.forEach((dep) => {
const pkgName = normalizePackageName(dep);
if (pkgName) {
fileDeps.add(pkgName);
fileDynamicImports.add(pkgName);
}
});
}
// 3. 조건부 require 감지
if (config.detection?.conditionalRequire) {
const conditionalReqs = detectConditionalRequires(content);
conditionalReqs.forEach((dep) => {
const pkgName = normalizePackageName(dep);
if (pkgName) {
fileDeps.add(pkgName);
fileConditionalReqs.add(pkgName);
}
});
}
return { fileDeps, fileDynamicImports, fileConditionalReqs };
};
// 모든 파일을 병렬로 처리
const results = await pMap(files, processFile, { concurrency });
// 결과 병합
results.forEach(({ fileDeps, fileDynamicImports, fileConditionalReqs }) => {
fileDeps.forEach(dep => used.add(dep));
fileDynamicImports.forEach(dep => dynamicUsed.add(dep));
fileConditionalReqs.forEach(dep => conditionalUsed.add(dep));
});
const DEFAULT_IGNORE_UNUSED = config.detection?.ignoreUnused || [
'eslint',
'prettier',
'jest',
'nodemon',
'webpack',
'vite',
'typescript',
'ts-node',
'@types/*',
];
// import되지 않을 수 있는 빌드 도구 및 개발 전용 패키지
const DEV_TOOLS = new Set([
'eslint',
'prettier',
'jest',
'mocha',
'chai',
'webpack',
'vite',
'rollup',
'parcel',
'typescript',
'ts-node',
'nodemon',
'concurrently',
'@swc/core',
'esbuild',
'babel',
'@babel/core',
'husky',
'lint-staged',
'commitlint',
]);
// 패키지가 개발 도구인지 확인
function isDevTool(pkgName) {
if (DEV_TOOLS.has(pkgName)) return true;
if (pkgName.startsWith('@types/')) return true;
if (pkgName.startsWith('eslint-')) return true;
if (pkgName.startsWith('babel-')) return true;
if (pkgName.startsWith('@babel/')) return true;
if (pkgName.startsWith('webpack-')) return true;
if (pkgName.startsWith('vite-')) return true;
return false;
}
const IGNORE_UNUSED = [...DEFAULT_IGNORE_UNUSED, ...getCustomIgnoreList(pkgJson)];
// 사용되지 않았고, ignore 목록에도 없는 패키지
const unused = declared.filter((dep) => {
if (used.has(dep)) return false;
if (IGNORE_UNUSED.includes(dep)) return false;
if (shouldIgnorePackage(dep, config)) return false;
// devDependency이고 알려진 개발 도구인 경우, 중간 신뢰도로 표시
if (devDependencies.includes(dep) && isDevTool(dep)) {
return false; // 개발 도구는 미사용으로 표시하지 않음
}
return true;
});
// devDependencies 분석 모드
const result = { unused, used: Array.from(used) };
if (config.analysisMode?.devDeps) {
result.unusedDev = devDependencies.filter(
(dep) => !used.has(dep) && !IGNORE_UNUSED.includes(dep) && !shouldIgnorePackage(dep, config),
);
result.unusedProd = dependencies.filter(
(dep) => !used.has(dep) && !IGNORE_UNUSED.includes(dep) && !shouldIgnorePackage(dep, config),
);
}
result.dynamicUsed = Array.from(dynamicUsed);
result.conditionalUsed = Array.from(conditionalUsed);
if (withUsedList) {
return result;
}
return unused;
}