vibe-janitor
Version:
A CLI tool that cleans AI-generated JavaScript/TypeScript projects efficiently and intelligently
551 lines (550 loc) • 22.9 kB
JavaScript
import fs from 'fs-extra';
import path from 'path';
import depcheck from 'depcheck';
import { Logger } from '../utils/logger.js';
/**
* Possible native replacements for common libraries
*/
const NATIVE_REPLACEMENTS = {
lodash: ['Array, Object, and String methods in modern JavaScript'],
moment: ['Intl.DateTimeFormat', 'Date methods', 'Temporal API (upcoming)'],
request: ['fetch API', 'node-fetch', 'axios'],
underscore: ['Array, Object, and String methods in modern JavaScript'],
jquery: ['querySelector', 'querySelectorAll', 'fetch API'],
bluebird: ['Native Promises', 'async/await'],
cheerio: ['DOMParser (browser)', 'JSDOM (Node)'],
q: ['Native Promises', 'async/await'],
async: ['Promise.all', 'Promise methods', 'async/await'],
mkdirp: ['fs.mkdir with recursive: true'],
};
/**
* Known dependency patterns that are often detected incorrectly
*/
const SPECIAL_DEPENDENCIES = {
// Framework plugins
frameworkPlugins: {
// Gatsby plugins are typically used in gatsby-config.js
gatsby: [/^gatsby-plugin-/, /^gatsby-source-/, /^gatsby-transformer-/],
// Next.js plugins
next: [/^next-/, '@next/bundle-analyzer', '@next/mdx', 'next-seo', 'next-themes'],
// Astro plugins
astro: [/^ \//, /^astro-/],
// Nuxt modules
nuxt: [/^ \//, /^nuxt-/],
},
// Testing libraries are used in test files
testingDependencies: [
// Jest and React Testing Library
/^jest/,
/^ -library/,
/^ \/jest/,
'identity-obj-proxy',
'babel-jest',
'ts-jest',
// Other testing frameworks
'vitest',
'cypress',
/^ \//,
'playwright',
/^ \//,
'mocha',
'chai',
'sinon',
'enzyme',
'ava',
'supertest',
'mock-service-worker',
'@mswjs/data',
],
// Type definitions are used implicitly
typeDefinitions: [/^ \//],
// Build tools and compilers
buildTools: [
// Webpack and loaders
'webpack',
/webpack-/,
/-webpack-/,
/-loader$/,
// Bundlers
'rollup',
/rollup-plugin-/,
'@rollup/plugin-',
'esbuild',
/esbuild-/,
'vite',
/vite-plugin-/,
'@vitejs/plugin-',
'parcel',
// Transpilers
'babel',
'@babel/',
/babel-/,
'swc',
'@swc/',
'typescript',
],
// CSS and styling tools
stylingTools: [
'postcss',
/postcss-/,
'autoprefixer',
'tailwindcss',
'sass',
'node-sass',
'less',
'stylus',
'styled-components',
'@emotion/react',
'@emotion/styled',
'css-loader',
'style-loader',
'css-modules',
'cssnano',
],
// Linters and formatters
lintingTools: [
'eslint',
/eslint-/,
'@eslint/',
'prettier',
/prettier-/,
'@prettier/',
'stylelint',
/stylelint-/,
'commitlint',
/commitlint-/,
'@commitlint/',
],
// Documentation tools
docTools: [
'jsdoc',
'typedoc',
'docusaurus',
'@docusaurus/',
'storybook',
'@storybook/',
'swagger',
'openapi',
],
// Monorepo tools
monorepoTools: ['lerna', 'nx', '@nrwl/', 'turborepo', 'turbo', 'workspaces'],
// Config and env management
configTools: [
'dotenv',
/dotenv-/,
'cross-env',
'env-cmd',
'config',
'convict',
'rc',
'cosmiconfig',
],
// Git and versioning tools
gitTools: [
'husky',
'lint-staged',
'commitizen',
'cz-conventional-changelog',
'standard-version',
'semantic-release',
'@semantic-release/',
],
// Common utilities often used in config files
utilityDependencies: [
'lodash',
'ramda',
'date-fns',
'dayjs',
'moment',
'luxon',
'zod',
'yup',
'ajv',
'joi',
'validator',
'uuid',
'nanoid',
'axios',
'node-fetch',
'isomorphic-fetch',
'graphql',
'apollo',
'@apollo/',
'urql',
'react-query',
'@tanstack/react-query',
],
};
/**
* Analyzes dependencies in the project using depcheck
*/
export class DependencyAuditor {
targetDir;
options;
constructor(targetDir, options = {}) {
this.targetDir = targetDir;
this.options = {
verbose: options.verbose ?? false,
ignoreDirs: options.ignoreDirs ?? ['node_modules', 'dist', 'build', 'coverage'],
ignoreMatches: options.ignoreMatches ?? [],
isGatsbyProject: options.isGatsbyProject ?? false,
isNextProject: options.isNextProject ?? false,
isNuxtProject: options.isNuxtProject ?? false,
isAstroProject: options.isAstroProject ?? false,
isMonorepo: options.isMonorepo ?? false,
detectFrameworks: options.detectFrameworks ?? true,
};
}
/**
* Check if a package matches any of the special dependency patterns
*/
isSpecialDependency(packageName) {
// If it's a Gatsby project, treat Gatsby plugins as used
if (this.options.isGatsbyProject && SPECIAL_DEPENDENCIES.frameworkPlugins.gatsby) {
if (this.matchesPattern(packageName, SPECIAL_DEPENDENCIES.frameworkPlugins.gatsby)) {
return true;
}
}
// For all other special dependencies categories
const categoriesToCheck = [
SPECIAL_DEPENDENCIES.testingDependencies,
SPECIAL_DEPENDENCIES.typeDefinitions,
SPECIAL_DEPENDENCIES.buildTools,
SPECIAL_DEPENDENCIES.stylingTools,
SPECIAL_DEPENDENCIES.lintingTools,
SPECIAL_DEPENDENCIES.docTools,
SPECIAL_DEPENDENCIES.monorepoTools,
SPECIAL_DEPENDENCIES.configTools,
SPECIAL_DEPENDENCIES.gitTools,
SPECIAL_DEPENDENCIES.utilityDependencies,
];
for (const category of categoriesToCheck) {
if (this.matchesPattern(packageName, category)) {
return true;
}
}
// Check against framework plugins
for (const [framework, patterns] of Object.entries(SPECIAL_DEPENDENCIES.frameworkPlugins)) {
// Skip Gatsby if not a Gatsby project (already checked above)
if (framework === 'gatsby' && this.options.isGatsbyProject)
continue;
if (this.matchesPattern(packageName, patterns)) {
return true;
}
}
// Didn't match any special dependency pattern
return false;
}
/**
* Helper method to check if a package name matches any pattern in a list
*/
matchesPattern(packageName, patterns) {
for (const pattern of patterns) {
if (typeof pattern === 'string') {
if (packageName === pattern)
return true;
}
else {
if (pattern.test(packageName))
return true;
}
}
return false;
}
/**
* Detect framework type from project structure
*/
async detectFrameworks() {
if (!this.options.detectFrameworks)
return;
const packageJsonPath = path.join(this.targetDir, 'package.json');
if (!fs.existsSync(packageJsonPath))
return;
try {
const packageJson = await fs.readJson(packageJsonPath);
const dependencies = {
...(packageJson.dependencies ?? {}),
...(packageJson.devDependencies ?? {}),
};
// Detect Gatsby
if ('gatsby' in dependencies) {
const hasGatsbyConfig = fs.existsSync(path.join(this.targetDir, 'gatsby-config.js')) ||
fs.existsSync(path.join(this.targetDir, 'gatsby-config.ts'));
this.options.isGatsbyProject = hasGatsbyConfig;
}
// Detect Next.js
if ('next' in dependencies) {
const hasNextConfig = fs.existsSync(path.join(this.targetDir, 'next.config.js')) ||
fs.existsSync(path.join(this.targetDir, 'next.config.ts')) ||
fs.existsSync(path.join(this.targetDir, 'next.config.mjs'));
this.options.isNextProject = hasNextConfig;
}
// Detect Nuxt.js
if ('nuxt' in dependencies || 'nuxt3' in dependencies) {
const hasNuxtConfig = fs.existsSync(path.join(this.targetDir, 'nuxt.config.js')) ||
fs.existsSync(path.join(this.targetDir, 'nuxt.config.ts'));
this.options.isNuxtProject = hasNuxtConfig;
}
// Detect Astro
if ('astro' in dependencies) {
const hasAstroConfig = fs.existsSync(path.join(this.targetDir, 'astro.config.js')) ||
fs.existsSync(path.join(this.targetDir, 'astro.config.ts')) ||
fs.existsSync(path.join(this.targetDir, 'astro.config.mjs'));
this.options.isAstroProject = hasAstroConfig;
}
// Detect if it's a monorepo
this.options.isMonorepo =
'lerna' in dependencies ||
'nx' in dependencies ||
'@nrwl/workspace' in dependencies ||
'turbo' in dependencies ||
fs.existsSync(path.join(this.targetDir, 'lerna.json')) ||
fs.existsSync(path.join(this.targetDir, 'nx.json')) ||
fs.existsSync(path.join(this.targetDir, 'turbo.json')) ||
fs.existsSync(path.join(this.targetDir, 'pnpm-workspace.yaml')) ||
packageJson.workspaces !== undefined;
// Detect common config files that indicate certain tools are in use
const configFiles = {
'postcss.config.js': 'postcss',
'tailwind.config.js': 'tailwindcss',
'.eslintrc.js': 'eslint',
'.eslintrc.json': 'eslint',
'.prettierrc': 'prettier',
'.prettierrc.js': 'prettier',
'jest.config.js': 'jest',
'cypress.json': 'cypress',
'cypress.config.js': 'cypress',
'playwright.config.js': 'playwright',
'vitest.config.js': 'vitest',
'vitest.config.ts': 'vitest',
'.storybook/main.js': 'storybook',
'webpack.config.js': 'webpack',
'rollup.config.js': 'rollup',
'vite.config.js': 'vite',
'tsconfig.json': 'typescript',
};
for (const [file, tool] of Object.entries(configFiles)) {
if (fs.existsSync(path.join(this.targetDir, file)) &&
!this.options.ignoreMatches?.includes(tool)) {
// If we find a config file for a tool, ensure it's not considered unused
this.options.ignoreMatches = [...(this.options.ignoreMatches ?? []), tool];
}
}
}
catch (error) {
if (this.options.verbose) {
Logger.warn(`Error detecting project frameworks: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
/**
* Run dependency audit on the project
*/
async audit() {
if (this.options.verbose) {
Logger.info('Analyzing project dependencies...');
}
try {
// Check if package.json exists
const packageJsonPath = path.join(this.targetDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`No package.json found in ${this.targetDir}`);
}
// Read package.json to get total dependencies count
const packageJson = await fs.readJson(packageJsonPath);
const dependencies = packageJson.dependencies ?? {};
const devDependencies = packageJson.devDependencies ?? {};
const totalDependencies = Object.keys(dependencies).length;
const totalDevDependencies = Object.keys(devDependencies).length;
// Auto-detect frameworks and project structure
await this.detectFrameworks();
if (this.options.verbose) {
if (this.options.isGatsbyProject) {
Logger.info('Detected Gatsby project. Will handle Gatsby plugins specially.');
}
if (this.options.isNextProject) {
Logger.info('Detected Next.js project. Will handle Next.js modules specially.');
}
if (this.options.isNuxtProject) {
Logger.info('Detected Nuxt.js project. Will handle Nuxt modules specially.');
}
if (this.options.isAstroProject) {
Logger.info('Detected Astro project. Will handle Astro integrations specially.');
}
if (this.options.isMonorepo) {
Logger.info('Detected monorepo structure. Will handle workspace dependencies specially.');
}
}
// Run depcheck with improved options
const options = {
ignoreDirs: this.options.ignoreDirs,
ignoreMatches: this.options.ignoreMatches,
// Include test pattern to better detect test dependencies
specials: [depcheck.special.jest, depcheck.special.bin, depcheck.special.eslint],
// Check more file types
detectors: [
depcheck.detector.requireCallExpression,
depcheck.detector.importDeclaration,
depcheck.detector.exportDeclaration,
],
};
const results = await depcheck(this.targetDir, options);
// Process results
let unusedDependencies = [...results.dependencies, ...results.devDependencies];
// Filter out special dependencies that are likely used but not detected
unusedDependencies = unusedDependencies.filter((dep) => !this.isSpecialDependency(dep));
// Check for dependencies in config files based on project type
try {
const configFilesToCheck = [];
// Add framework-specific config files
if (this.options.isGatsbyProject) {
configFilesToCheck.push('gatsby-config.js', 'gatsby-config.ts', 'gatsby-node.js', 'gatsby-node.ts', 'gatsby-browser.js', 'gatsby-browser.ts', 'gatsby-ssr.js', 'gatsby-ssr.ts');
}
if (this.options.isNextProject) {
configFilesToCheck.push('next.config.js', 'next.config.ts', 'next.config.mjs');
}
if (this.options.isNuxtProject) {
configFilesToCheck.push('nuxt.config.js', 'nuxt.config.ts');
}
if (this.options.isAstroProject) {
configFilesToCheck.push('astro.config.js', 'astro.config.ts', 'astro.config.mjs');
}
// Add common config files that might reference dependencies
configFilesToCheck.push(
// Build tools
'webpack.config.js', 'rollup.config.js', 'vite.config.js', 'vite.config.ts',
// Linting and formatting
'.eslintrc.js', '.eslintrc.json', '.prettierrc.js',
// Testing
'jest.config.js', 'cypress.config.js', 'playwright.config.js', 'vitest.config.js',
// Documentation
'.storybook/main.js',
// Styling
'postcss.config.js', 'tailwind.config.js',
// Monorepo
'lerna.json', 'nx.json', 'turbo.json');
// Read and check all config files for references to dependencies
for (const configFile of configFilesToCheck) {
const configPath = path.join(this.targetDir, configFile);
if (fs.existsSync(configPath)) {
const configContent = await fs.readFile(configPath, 'utf-8');
// Filter out any dependency that is mentioned in the config file
// This simple string-based approach isn't perfect but catches most cases
unusedDependencies = unusedDependencies.filter((dep) => {
// Check if the dependency is mentioned in quotes (single or double)
const isReferenced = configContent.includes(`'${dep}'`) ||
configContent.includes(`"${dep}"`) ||
configContent.includes(`from '${dep}'`) ||
configContent.includes(`from "${dep}"`) ||
configContent.includes(`require('${dep}')`) ||
configContent.includes(`require("${dep}")`) ||
configContent.includes(`import '${dep}'`) ||
configContent.includes(`import "${dep}"`);
return !isReferenced;
});
}
}
// For monorepos, check workspace packages references
if (this.options.isMonorepo) {
const packageJsonPath = path.join(this.targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
if (packageJson.workspaces) {
// Filter out any workspace packages (often start with @ or match workspace patterns)
unusedDependencies = unusedDependencies.filter((dep) => {
// If it's a workspace local package reference, keep it
if (dep.startsWith('@') && dep.includes('/')) {
// This might be a workspace package (e.g., @myorg/package)
return false;
}
return true;
});
}
}
}
}
catch (error) {
if (this.options.verbose) {
Logger.warn(`Error checking config files: ${error instanceof Error ? error.message : String(error)}`);
}
}
const missingDependencies = Object.keys(results.missing);
// Check for @reach/router which is often a false positive for Gatsby projects
if (this.options.isGatsbyProject) {
const reachRouterIndex = missingDependencies.indexOf('@reach/router');
if (reachRouterIndex !== -1) {
// Remove @reach/router as it's a dependency of Gatsby itself
missingDependencies.splice(reachRouterIndex, 1);
}
}
// Find possible native replacements
const possibleNativeReplacements = unusedDependencies
.filter((dep) => NATIVE_REPLACEMENTS[dep])
.map((dep) => ({
package: dep,
alternatives: NATIVE_REPLACEMENTS[dep],
}));
if (this.options.verbose) {
Logger.info(`Found ${unusedDependencies.length} unused dependencies`);
Logger.info(`Found ${missingDependencies.length} missing dependencies`);
Logger.info(`Found ${possibleNativeReplacements.length} packages that could be replaced with native alternatives`);
}
return {
unusedDependencies,
missingDependencies,
possibleNativeReplacements,
totalDependencies,
totalDevDependencies,
};
}
catch (error) {
Logger.error(`Dependency audit failed: ${error instanceof Error ? error.message : String(error)}`);
return {
unusedDependencies: [],
missingDependencies: [],
possibleNativeReplacements: [],
totalDependencies: 0,
totalDevDependencies: 0,
};
}
}
/**
* Generate package.json cleanup instructions
*/
generateCleanupInstructions(results) {
if (results.unusedDependencies.length === 0 && results.missingDependencies.length === 0) {
return 'No unused or missing dependencies found. Your package.json is clean!';
}
let instructions = '# Dependency Cleanup Instructions\n\n';
if (results.unusedDependencies.length > 0) {
instructions += '## Unused Dependencies\n\n';
instructions += 'These dependencies appear to be unused and can be removed:\n\n';
instructions += '```bash\n';
instructions += `npm uninstall ${results.unusedDependencies.join(' ')}\n`;
instructions += '```\n\n';
instructions +=
'**Note:** Some dependencies might be used in ways that automated tools cannot detect. ';
instructions += 'Please verify each dependency before removing it.\n\n';
}
if (results.possibleNativeReplacements.length > 0) {
instructions += '## Native Replacements\n\n';
instructions += 'These dependencies could be replaced with native alternatives:\n\n';
results.possibleNativeReplacements.forEach((item) => {
instructions += `### ${item.package}\n\n`;
instructions += 'Alternatives:\n';
item.alternatives.forEach((alt) => {
instructions += `- ${alt}\n`;
});
instructions += '\n';
});
}
if (results.missingDependencies.length > 0) {
instructions += '## Missing Dependencies\n\n';
instructions += 'These dependencies are used but not listed in package.json:\n\n';
instructions += '```bash\n';
instructions += `npm install --save ${results.missingDependencies.join(' ')}\n`;
instructions += '```\n\n';
}
return instructions;
}
}