helpforbegs 0.0.0-stage â 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bin/cli.js +108 -0
- package/package.json +43 -4
- package/src/analyzer.js +348 -0
- package/src/args.js +116 -0
- package/src/astParser.js +228 -0
- package/src/binMapper.js +167 -0
- package/src/configReader.js +351 -0
- package/src/fallbackParser.js +130 -0
- package/src/fileFinder.js +196 -0
- package/src/messages.js +82 -0
- package/src/remover.js +184 -0
- package/src/reporter.js +186 -0
- package/src/safeList.js +91 -0
- package/src/scorer.js +150 -0
- package/src/why.js +103 -0
package/src/messages.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/messages.js
|
|
2
|
+
// WHY: Keeping all human-facing text in one place makes it easy to maintain,
|
|
3
|
+
// translate, review tone, and switch cleanly between friendly mode and roast mode.
|
|
4
|
+
|
|
5
|
+
const NORMAL_MESSAGES = {
|
|
6
|
+
scanHeader: (filesCount) => `đ Scanning your project... (${filesCount} files read)`,
|
|
7
|
+
monorepoNotice: (count) => `đĻ Monorepo detected: merged dependencies across ${count} workspace folders.`,
|
|
8
|
+
missingNodeModulesNotice: 'đĄ Tip: node_modules is missing. Run `npm install` for accurate folder sizes and script binary checks.',
|
|
9
|
+
largeFileSkipped: (file, sizeMb) => `âšī¸ Skipped ${file} (${sizeMb} MB) because it exceeds the 1 MB safety limit.`,
|
|
10
|
+
parseWarning: (warning) => `â ī¸ ${warning} (fell back to regex scanning).`,
|
|
11
|
+
|
|
12
|
+
unusedHeader: (count) => `â Installed but never used (${count})`,
|
|
13
|
+
probablyHeader: (count) => `đ¤ Probably unused (${count})`,
|
|
14
|
+
missingHeader: (count) => `â ī¸ Used but not installed (${count})`,
|
|
15
|
+
usedHeader: (count) => `â
${count} package${count === 1 ? ' is' : 's are'} used correctly`,
|
|
16
|
+
|
|
17
|
+
unusedItem: (pkg, reason) => `You installed ${pkg} but never used it.`,
|
|
18
|
+
probablyItem: (pkg, reason) => `${reason || 'Detected as tooling with no direct script references; double check before removing.'}`,
|
|
19
|
+
missingItem: (pkg, firstLoc) => `Used in ${firstLoc.file}:${firstLoc.line} but missing from package.json.`,
|
|
20
|
+
|
|
21
|
+
scoreLabel: (score, grade, comment) => `đ Health score: ${score}/100 (${grade}) "${comment}"`,
|
|
22
|
+
spaceSaved: (sizeStr) => `đž You could free about ${sizeStr}.`,
|
|
23
|
+
cleanAllGood: 'đ Congratulations! Your project dependencies are squeaky clean!',
|
|
24
|
+
runRemoveTip: 'đĄ Run `npx helpforbegs remove` to clean up safely.',
|
|
25
|
+
runDryRunTip: 'đĄ Run `npx helpforbegs remove --dry-run` to preview cleanup.',
|
|
26
|
+
|
|
27
|
+
gradeComments: {
|
|
28
|
+
'A+': 'Spotless! Everything installed has a clear purpose.',
|
|
29
|
+
'A': 'Excellent! Clean, lean, and very well-organized.',
|
|
30
|
+
'B': 'Pretty tidy, a few dusty corners.',
|
|
31
|
+
'C': 'Getting cluttered; time for a quick spring cleaning.',
|
|
32
|
+
'D': 'Heavy bag of unused packages slowing you down.',
|
|
33
|
+
'F': 'Critical issues found! Missing packages could break your app at runtime.',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ROAST_MESSAGES = {
|
|
38
|
+
scanHeader: (filesCount) => `đ Inspecting the crime scene... (${filesCount} files judged)`,
|
|
39
|
+
monorepoNotice: (count) => `đĻ Monorepo alert: you brought ${count} workspaces into this mess.`,
|
|
40
|
+
missingNodeModulesNotice: 'đĄ Even node_modules refused to show up. Run `npm install` so we can see the full damage.',
|
|
41
|
+
largeFileSkipped: (file, sizeMb) => `đ Skipped ${file} (${sizeMb} MB). Did you accidentally commit a movie?`,
|
|
42
|
+
parseWarning: (warning) => `â ī¸ ${warning}. Your syntax confused our parser, so we squinted at it with regex.`,
|
|
43
|
+
|
|
44
|
+
unusedHeader: (count) => `â Digital hoarders club (${count} ghost dependencies)`,
|
|
45
|
+
probablyHeader: (count) => `đ¤ Living on borrowed time (${count} mystery packages)`,
|
|
46
|
+
missingHeader: (count) => `â ī¸ Playing with fire: imported but not installed (${count})`,
|
|
47
|
+
usedHeader: (count) => `â
${count} package${count === 1 ? '' : 's'} actually paying rent in your code`,
|
|
48
|
+
|
|
49
|
+
unusedItem: (pkg) => {
|
|
50
|
+
const jokes = [
|
|
51
|
+
`You installed ${pkg} months ago and never spoke to it again.`,
|
|
52
|
+
`${pkg} is just taking up disk space and emotional energy.`,
|
|
53
|
+
`Did you follow a 2017 tutorial and forget you installed ${pkg}?`,
|
|
54
|
+
`${pkg} has been ghosted harder than a bad first date.`,
|
|
55
|
+
];
|
|
56
|
+
// Return a consistent joke based on string hash
|
|
57
|
+
const index = Math.abs(pkg.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)) % jokes.length;
|
|
58
|
+
return jokes[index];
|
|
59
|
+
},
|
|
60
|
+
probablyItem: (pkg, reason) => `We saw ${pkg} lurking in the shadows. Check if you still need it before deleting.`,
|
|
61
|
+
missingItem: (pkg, firstLoc) => `You imported ${pkg} in ${firstLoc.file}:${firstLoc.line} on pure faith without installing it!`,
|
|
62
|
+
|
|
63
|
+
scoreLabel: (score, grade, comment) => `đ Health roast: ${score}/100 (${grade}) "${comment}"`,
|
|
64
|
+
spaceSaved: (sizeStr) => `đž Removing these dead weights would free ${sizeStr} of sweet disk space.`,
|
|
65
|
+
cleanAllGood: 'đ Wow, not a single wasted byte. Are you sure you are a beginner?',
|
|
66
|
+
runRemoveTip: 'đĄ Take out the trash with `npx helpforbegs remove`.',
|
|
67
|
+
runDryRunTip: 'đĄ Too scared? Try `npx helpforbegs remove --dry-run` first.',
|
|
68
|
+
|
|
69
|
+
gradeComments: {
|
|
70
|
+
'A+': 'Suspiciously perfect. Did you write this in a cleanroom?',
|
|
71
|
+
'A': 'Solid work! Marie Kondo would approve of this repo.',
|
|
72
|
+
'B': 'Not bad, but a few neglected packages are crying for attention.',
|
|
73
|
+
'C': 'Your package.json looks like an unfinished garage sale.',
|
|
74
|
+
'D': 'More unused weight than a gym membership in March.',
|
|
75
|
+
'F': 'An absolute danger zone! Your app will crash if you deploy this.',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
NORMAL_MESSAGES,
|
|
81
|
+
ROAST_MESSAGES,
|
|
82
|
+
};
|
package/src/remover.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/remover.js
|
|
2
|
+
// WHY: Deleting dependencies can be scary for beginners.
|
|
3
|
+
// This module provides triple safety:
|
|
4
|
+
// 1. Shows you the exact list before touching anything.
|
|
5
|
+
// 2. Creates a timestamped backup of your package.json first.
|
|
6
|
+
// 3. Detects your exact package manager (npm, yarn, pnpm, or bun) so your lockfile stays valid.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const readline = require('readline');
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const { analyzeProject } = require('./analyzer');
|
|
13
|
+
const { calculateScoreAndSavings } = require('./scorer');
|
|
14
|
+
const { createStyler } = require('./reporter');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detects which package manager the project uses by looking for lockfiles.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} projectDir
|
|
20
|
+
* @returns {{ name: string, uninstallCmd: string[] }}
|
|
21
|
+
*/
|
|
22
|
+
function detectPackageManager(projectDir) {
|
|
23
|
+
if (fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml'))) {
|
|
24
|
+
return { name: 'pnpm', uninstallCmd: ['remove'] };
|
|
25
|
+
}
|
|
26
|
+
if (fs.existsSync(path.join(projectDir, 'yarn.lock'))) {
|
|
27
|
+
return { name: 'yarn', uninstallCmd: ['remove'] };
|
|
28
|
+
}
|
|
29
|
+
if (fs.existsSync(path.join(projectDir, 'bun.lockb')) || fs.existsSync(path.join(projectDir, 'bun.lock'))) {
|
|
30
|
+
return { name: 'bun', uninstallCmd: ['remove'] };
|
|
31
|
+
}
|
|
32
|
+
// Default to npm
|
|
33
|
+
return { name: 'npm', uninstallCmd: ['uninstall'] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Prompts the user with a y/N question in the terminal.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} question
|
|
40
|
+
* @returns {Promise<boolean>}
|
|
41
|
+
*/
|
|
42
|
+
function askConfirmation(question) {
|
|
43
|
+
const rl = readline.createInterface({
|
|
44
|
+
input: process.stdin,
|
|
45
|
+
output: process.stdout,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
rl.question(question, (answer) => {
|
|
50
|
+
rl.close();
|
|
51
|
+
const trimmed = answer.trim().toLowerCase();
|
|
52
|
+
resolve(trimmed === 'y' || trimmed === 'yes');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Executes the safe package removal process.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} projectDir
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @param {boolean} [options.dryRun=false]
|
|
63
|
+
* @param {boolean} [options.yes=false]
|
|
64
|
+
* @param {boolean} [options.isInteractive=true]
|
|
65
|
+
* @param {boolean} [options.noColor=false]
|
|
66
|
+
* @returns {Promise<number>} Exit code (0 = success, 1 = aborted or failed)
|
|
67
|
+
*/
|
|
68
|
+
async function removeUnused(projectDir, options = {}) {
|
|
69
|
+
const styler = createStyler(options.noColor);
|
|
70
|
+
const pkgJsonPath = path.join(projectDir, 'package.json');
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
73
|
+
console.log(styler.red(`â No package.json found in ${projectDir}`));
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const analysis = analyzeProject(projectDir);
|
|
78
|
+
const { sizedUnused } = calculateScoreAndSavings(analysis);
|
|
79
|
+
|
|
80
|
+
if (sizedUnused.length === 0) {
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(styler.green('⨠No unused packages found! Your project is already tidy.'));
|
|
83
|
+
console.log();
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(styler.bold(`Found ${sizedUnused.length} unused package${sizedUnused.length === 1 ? '' : 's'} ready for removal:`));
|
|
89
|
+
console.log();
|
|
90
|
+
|
|
91
|
+
for (const item of sizedUnused) {
|
|
92
|
+
const sizeStr = item.bytes > 0 ? styler.gray(`(${item.formattedSize})`) : '';
|
|
93
|
+
console.log(` âĸ ${styler.red(item.name)} ${sizeStr}`);
|
|
94
|
+
}
|
|
95
|
+
console.log();
|
|
96
|
+
|
|
97
|
+
const pm = detectPackageManager(projectDir);
|
|
98
|
+
const pkgsToRemove = sizedUnused.map((u) => u.name);
|
|
99
|
+
const timestamp = Date.now();
|
|
100
|
+
const backupFileName = `package.json.helpforbegs.${timestamp}.bak`;
|
|
101
|
+
const backupFilePath = path.join(projectDir, backupFileName);
|
|
102
|
+
|
|
103
|
+
// Dry-run mode: explain what would happen without altering files
|
|
104
|
+
if (options.dryRun) {
|
|
105
|
+
console.log(styler.cyan(styler.bold('đ [DRY RUN] Simulation mode active. No changes will be made:')));
|
|
106
|
+
console.log(` 1. Would create backup at: ${backupFileName}`);
|
|
107
|
+
console.log(` 2. Would run: ${pm.name} ${pm.uninstallCmd.join(' ')} ${pkgsToRemove.join(' ')}`);
|
|
108
|
+
console.log();
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Non-interactive or CI environments without --yes: refuse to run
|
|
113
|
+
if (!options.isInteractive && !options.yes) {
|
|
114
|
+
console.log(styler.yellow('â ī¸ Interactive confirmation is disabled in non-interactive / CI environments.'));
|
|
115
|
+
console.log(styler.dim(' Run again with `--yes` to confirm removal automatically:'));
|
|
116
|
+
console.log(` npx helpforbegs remove --yes`);
|
|
117
|
+
console.log();
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Prompt user for confirmation unless --yes was passed
|
|
122
|
+
if (!options.yes) {
|
|
123
|
+
const confirmed = await askConfirmation(
|
|
124
|
+
styler.bold(`Are you sure you want to uninstall these ${pkgsToRemove.length} packages? (y/N): `)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!confirmed) {
|
|
128
|
+
console.log();
|
|
129
|
+
console.log(styler.gray('Cleanup cancelled. No changes were made.'));
|
|
130
|
+
console.log();
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 1. Create timestamped backup of package.json
|
|
136
|
+
try {
|
|
137
|
+
fs.copyFileSync(pkgJsonPath, backupFilePath);
|
|
138
|
+
console.log();
|
|
139
|
+
console.log(styler.green(`đĄī¸ Created backup: ${backupFileName}`));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.log(styler.red(`â Failed to create backup file: ${err.message}`));
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. Run the package manager uninstall command
|
|
146
|
+
console.log(styler.cyan(`đ Running \`${pm.name} ${pm.uninstallCmd.join(' ')} ${pkgsToRemove.join(' ')}\`...`));
|
|
147
|
+
console.log();
|
|
148
|
+
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const child = spawn(pm.name, [...pm.uninstallCmd, ...pkgsToRemove], {
|
|
151
|
+
cwd: projectDir,
|
|
152
|
+
stdio: 'inherit',
|
|
153
|
+
shell: process.platform === 'win32', // Use shell on Windows for resolving npm.cmd
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
child.on('error', (err) => {
|
|
157
|
+
console.log();
|
|
158
|
+
console.log(styler.red(`â Failed to execute ${pm.name}: ${err.message}`));
|
|
159
|
+
console.log(styler.yellow(` Your package.json backup is saved at: ${backupFileName}`));
|
|
160
|
+
resolve(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
child.on('close', (code) => {
|
|
164
|
+
console.log();
|
|
165
|
+
if (code === 0) {
|
|
166
|
+
console.log(styler.green('đ Successfully uninstalled unused packages!'));
|
|
167
|
+
console.log(styler.dim(` (Backup kept at ${backupFileName} just in case you ever want it back)`));
|
|
168
|
+
console.log();
|
|
169
|
+
resolve(0);
|
|
170
|
+
} else {
|
|
171
|
+
console.log(styler.red(`â ī¸ ${pm.name} exited with status code ${code}.`));
|
|
172
|
+
console.log(styler.yellow(` You can restore your original package.json from: ${backupFileName}`));
|
|
173
|
+
console.log();
|
|
174
|
+
resolve(1);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
removeUnused,
|
|
182
|
+
detectPackageManager,
|
|
183
|
+
askConfirmation,
|
|
184
|
+
};
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/reporter.js
|
|
2
|
+
// WHY: A good CLI should feel friendly, readable, and encouraging.
|
|
3
|
+
// We format findings with clean indentation, gentle colors, and clear emojis.
|
|
4
|
+
// When piped or run in CI, colors are stripped automatically so logs stay crisp.
|
|
5
|
+
|
|
6
|
+
const chalk = require('chalk');
|
|
7
|
+
const { NORMAL_MESSAGES, ROAST_MESSAGES } = require('./messages');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a color wrapper that respects the user's --no-color setting and environment.
|
|
11
|
+
*
|
|
12
|
+
* @param {boolean} noColor
|
|
13
|
+
*/
|
|
14
|
+
function createStyler(noColor) {
|
|
15
|
+
if (noColor) {
|
|
16
|
+
const identity = (str) => String(str);
|
|
17
|
+
return {
|
|
18
|
+
bold: identity,
|
|
19
|
+
dim: identity,
|
|
20
|
+
red: identity,
|
|
21
|
+
yellow: identity,
|
|
22
|
+
green: identity,
|
|
23
|
+
cyan: identity,
|
|
24
|
+
gray: identity,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return chalk;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Formats and prints the complete CLI report to stdout.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} analysis - Result from analyzer.analyzeProject
|
|
34
|
+
* @param {object} scoreData - Result from scorer.calculateScoreAndSavings
|
|
35
|
+
* @param {object} [options]
|
|
36
|
+
* @param {boolean} [options.roast=false]
|
|
37
|
+
* @param {boolean} [options.noColor=false]
|
|
38
|
+
* @param {boolean} [options.json=false]
|
|
39
|
+
* @param {boolean} [options.scoreOnly=false]
|
|
40
|
+
* @returns {number} The recommended process exit code (0 = clean, 1 = issues found)
|
|
41
|
+
*/
|
|
42
|
+
function reportResults(analysis, scoreData, options = {}) {
|
|
43
|
+
const isRoast = Boolean(options.roast);
|
|
44
|
+
const msgBundle = isRoast ? ROAST_MESSAGES : NORMAL_MESSAGES;
|
|
45
|
+
const styler = createStyler(options.noColor);
|
|
46
|
+
|
|
47
|
+
// 1. JSON mode: output raw structured data for scripts and tools
|
|
48
|
+
if (options.json) {
|
|
49
|
+
const output = {
|
|
50
|
+
project: analysis.rootPkgJson.name || 'unnamed-project',
|
|
51
|
+
version: analysis.rootPkgJson.version || '1.0.0',
|
|
52
|
+
filesScanned: analysis.filesReadCount,
|
|
53
|
+
isMonorepo: analysis.isMonorepo,
|
|
54
|
+
workspacesCount: analysis.workspacesCount,
|
|
55
|
+
missingNodeModules: analysis.missingNodeModules,
|
|
56
|
+
health: {
|
|
57
|
+
score: scoreData.score,
|
|
58
|
+
letterGrade: scoreData.letterGrade,
|
|
59
|
+
comment: scoreData.comment,
|
|
60
|
+
potentialSavingsBytes: scoreData.totalBytesSaved,
|
|
61
|
+
potentialSavingsFormatted: scoreData.formattedSpaceSaved,
|
|
62
|
+
},
|
|
63
|
+
unused: scoreData.sizedUnused.map((u) => ({
|
|
64
|
+
name: u.name,
|
|
65
|
+
isDev: u.isDev,
|
|
66
|
+
sizeBytes: u.bytes,
|
|
67
|
+
formattedSize: u.formattedSize,
|
|
68
|
+
reason: u.reason,
|
|
69
|
+
})),
|
|
70
|
+
probablyUnused: analysis.probablyUnused,
|
|
71
|
+
missing: analysis.missing,
|
|
72
|
+
usedCount: analysis.used.size,
|
|
73
|
+
skippedLargeFiles: analysis.skippedLargeFiles,
|
|
74
|
+
parseWarnings: analysis.parseWarnings,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
console.log(JSON.stringify(output, null, 2));
|
|
78
|
+
const hasIssues = scoreData.sizedUnused.length > 0 || analysis.missing.length > 0 || analysis.probablyUnused.length > 0;
|
|
79
|
+
return hasIssues ? 1 : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Score-only mode: `helpforbegs score`
|
|
83
|
+
if (options.scoreOnly) {
|
|
84
|
+
console.log(
|
|
85
|
+
styler.bold(
|
|
86
|
+
msgBundle.scoreLabel(scoreData.score, scoreData.letterGrade, scoreData.comment)
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
const hasIssues = scoreData.sizedUnused.length > 0 || analysis.missing.length > 0 || analysis.probablyUnused.length > 0;
|
|
90
|
+
return hasIssues ? 1 : 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Full human-friendly report:
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(styler.cyan(msgBundle.scanHeader(analysis.filesReadCount)));
|
|
96
|
+
|
|
97
|
+
// Monorepo notice
|
|
98
|
+
if (analysis.isMonorepo) {
|
|
99
|
+
console.log(styler.dim(` ${msgBundle.monorepoNotice(analysis.workspacesCount)}`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Missing node_modules warning
|
|
103
|
+
if (analysis.missingNodeModules) {
|
|
104
|
+
console.log(styler.yellow(` ${msgBundle.missingNodeModulesNotice}`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Skipped large files
|
|
108
|
+
for (const skipped of analysis.skippedLargeFiles) {
|
|
109
|
+
const sizeMb = (skipped.size / (1024 * 1024)).toFixed(1);
|
|
110
|
+
console.log(styler.gray(` ${msgBundle.largeFileSkipped(skipped.relativePath, sizeMb)}`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Parse warnings
|
|
114
|
+
for (const warning of analysis.parseWarnings) {
|
|
115
|
+
console.log(styler.gray(` ${msgBundle.parseWarning(warning)}`));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log();
|
|
119
|
+
|
|
120
|
+
// Section: Unused packages
|
|
121
|
+
if (scoreData.sizedUnused.length > 0) {
|
|
122
|
+
console.log(styler.red(styler.bold(msgBundle.unusedHeader(scoreData.sizedUnused.length))));
|
|
123
|
+
for (const item of scoreData.sizedUnused) {
|
|
124
|
+
const sizeTag = item.bytes > 0 ? styler.gray(`(${item.formattedSize})`.padEnd(10)) : ''.padEnd(10);
|
|
125
|
+
const pkgFormatted = styler.bold(item.name.padEnd(14));
|
|
126
|
+
const message = msgBundle.unusedItem(item.name, item.reason);
|
|
127
|
+
console.log(` âĸ ${pkgFormatted} ${sizeTag} ${message}`);
|
|
128
|
+
}
|
|
129
|
+
console.log();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Section: Probably unused
|
|
133
|
+
if (analysis.probablyUnused.length > 0) {
|
|
134
|
+
console.log(styler.yellow(styler.bold(msgBundle.probablyHeader(analysis.probablyUnused.length))));
|
|
135
|
+
for (const item of analysis.probablyUnused) {
|
|
136
|
+
const pkgFormatted = styler.bold(item.name.padEnd(14));
|
|
137
|
+
const message = msgBundle.probablyItem(item.name, item.reason);
|
|
138
|
+
console.log(` âĸ ${pkgFormatted} ${message}`);
|
|
139
|
+
}
|
|
140
|
+
console.log();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Section: Missing packages
|
|
144
|
+
if (analysis.missing.length > 0) {
|
|
145
|
+
console.log(styler.red(styler.bold(msgBundle.missingHeader(analysis.missing.length))));
|
|
146
|
+
for (const item of analysis.missing) {
|
|
147
|
+
const pkgFormatted = styler.bold(item.name.padEnd(14));
|
|
148
|
+
const firstLoc = item.locations[0] || { file: 'unknown', line: 1 };
|
|
149
|
+
const message = msgBundle.missingItem(item.name, firstLoc);
|
|
150
|
+
console.log(` âĸ ${pkgFormatted} ${message}`);
|
|
151
|
+
}
|
|
152
|
+
console.log();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Section: Used packages count
|
|
156
|
+
if (analysis.used.size > 0) {
|
|
157
|
+
console.log(styler.green(msgBundle.usedHeader(analysis.used.size)));
|
|
158
|
+
console.log();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Section: Health Score & Potential Disk Space Saved
|
|
162
|
+
console.log(styler.bold(msgBundle.scoreLabel(scoreData.score, scoreData.letterGrade, scoreData.comment)));
|
|
163
|
+
if (scoreData.totalBytesSaved > 0) {
|
|
164
|
+
console.log(styler.cyan(msgBundle.spaceSaved(scoreData.formattedSpaceSaved)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log();
|
|
168
|
+
|
|
169
|
+
// Recommendations
|
|
170
|
+
if (scoreData.sizedUnused.length > 0) {
|
|
171
|
+
console.log(styler.dim(msgBundle.runRemoveTip));
|
|
172
|
+
} else if (analysis.missing.length === 0 && analysis.probablyUnused.length === 0) {
|
|
173
|
+
console.log(styler.green(msgBundle.cleanAllGood));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log();
|
|
177
|
+
|
|
178
|
+
// Exit code: 0 if clean, 1 if any unused, missing, or probablyUnused items exist
|
|
179
|
+
const hasIssues = scoreData.sizedUnused.length > 0 || analysis.missing.length > 0 || analysis.probablyUnused.length > 0;
|
|
180
|
+
return hasIssues ? 1 : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
reportResults,
|
|
185
|
+
createStyler,
|
|
186
|
+
};
|
package/src/safeList.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/safeList.js
|
|
2
|
+
// WHY: Many essential development packages are run directly from the command line,
|
|
3
|
+
// loaded via CLI hooks, or invoked by build systems, so they never appear in `require()` or `import`
|
|
4
|
+
// inside source code. Flagging them as "unused" would confuse beginners and break their builds.
|
|
5
|
+
// Keeping this list in a dedicated, clean file makes it easy to maintain and expand.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Built-in safe-list of popular development tools, linters, transpilers,
|
|
9
|
+
* test frameworks, and CLI helpers.
|
|
10
|
+
*/
|
|
11
|
+
const SAFE_LIST = new Set([
|
|
12
|
+
// Compilers & Language Tools
|
|
13
|
+
'typescript',
|
|
14
|
+
'ts-node',
|
|
15
|
+
'tsx',
|
|
16
|
+
'tslib',
|
|
17
|
+
'@babel/core',
|
|
18
|
+
'@babel/cli',
|
|
19
|
+
'@babel/preset-env',
|
|
20
|
+
'@babel/preset-react',
|
|
21
|
+
'@babel/preset-typescript',
|
|
22
|
+
|
|
23
|
+
// Linters & Formatters
|
|
24
|
+
'eslint',
|
|
25
|
+
'prettier',
|
|
26
|
+
'lint-staged',
|
|
27
|
+
'husky',
|
|
28
|
+
'commitlint',
|
|
29
|
+
'@commitlint/cli',
|
|
30
|
+
'@commitlint/config-conventional',
|
|
31
|
+
|
|
32
|
+
// Bundlers & Build Tools
|
|
33
|
+
'webpack',
|
|
34
|
+
'webpack-cli',
|
|
35
|
+
'webpack-dev-server',
|
|
36
|
+
'vite',
|
|
37
|
+
'esbuild',
|
|
38
|
+
'rollup',
|
|
39
|
+
'parcel',
|
|
40
|
+
'turbo',
|
|
41
|
+
|
|
42
|
+
// Testing Frameworks & Runners
|
|
43
|
+
'jest',
|
|
44
|
+
'vitest',
|
|
45
|
+
'mocha',
|
|
46
|
+
'chai',
|
|
47
|
+
'supertest',
|
|
48
|
+
'c8',
|
|
49
|
+
'nyc',
|
|
50
|
+
'cypress',
|
|
51
|
+
'playwright',
|
|
52
|
+
|
|
53
|
+
// CLI Utilities & Task Runners
|
|
54
|
+
'nodemon',
|
|
55
|
+
'pm2',
|
|
56
|
+
'rimraf',
|
|
57
|
+
'concurrently',
|
|
58
|
+
'npm-run-all',
|
|
59
|
+
'cross-env',
|
|
60
|
+
'shx',
|
|
61
|
+
'dotenv-cli',
|
|
62
|
+
'wait-on',
|
|
63
|
+
'serve',
|
|
64
|
+
|
|
65
|
+
// Styling & CSS Processors (invoked by PostCSS/Tailwind CLI or build tools)
|
|
66
|
+
'postcss',
|
|
67
|
+
'postcss-cli',
|
|
68
|
+
'autoprefixer',
|
|
69
|
+
'tailwindcss',
|
|
70
|
+
|
|
71
|
+
// Documentation & Deployment
|
|
72
|
+
'typedoc',
|
|
73
|
+
'jsdoc',
|
|
74
|
+
'gh-pages',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Checks if a given package name is in our built-in tooling safe-list.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} packageName
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
function isSafeListed(packageName) {
|
|
84
|
+
if (!packageName) return false;
|
|
85
|
+
return SAFE_LIST.has(packageName.toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
SAFE_LIST,
|
|
90
|
+
isSafeListed,
|
|
91
|
+
};
|
package/src/scorer.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/scorer.js
|
|
2
|
+
// WHY: Numbers and grades give beginners immediate, intuitive feedback.
|
|
3
|
+
// Showing how much disk space can be saved (e.g. "Free about 5.6 MB")
|
|
4
|
+
// turns abstract dependency cleanup into a tangible, rewarding achievement.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { NORMAL_MESSAGES, ROAST_MESSAGES } = require('./messages');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recursively calculates the byte size of a folder on disk.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} dirPath
|
|
14
|
+
* @returns {number} Size in bytes
|
|
15
|
+
*/
|
|
16
|
+
function getDirectorySize(dirPath) {
|
|
17
|
+
let totalBytes = 0;
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(dirPath)) {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const stats = fs.statSync(dirPath);
|
|
25
|
+
if (!stats.isDirectory()) {
|
|
26
|
+
return stats.size;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
32
|
+
try {
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
totalBytes += getDirectorySize(fullPath);
|
|
35
|
+
} else if (entry.isFile()) {
|
|
36
|
+
const fileStat = fs.statSync(fullPath);
|
|
37
|
+
totalBytes += fileStat.size;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Skip unreadable files or broken symlinks
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return totalBytes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Formats a byte number into a clean, human-readable string (KB or MB).
|
|
52
|
+
*
|
|
53
|
+
* @param {number} bytes
|
|
54
|
+
* @returns {string} e.g. "1.4 MB" or "320 KB"
|
|
55
|
+
*/
|
|
56
|
+
function formatBytes(bytes) {
|
|
57
|
+
if (bytes <= 0) return '0 KB';
|
|
58
|
+
const kb = bytes / 1024;
|
|
59
|
+
if (kb < 1000) {
|
|
60
|
+
return `${Math.round(kb)} KB`;
|
|
61
|
+
}
|
|
62
|
+
const mb = kb / 1024;
|
|
63
|
+
return `${mb.toFixed(1)} MB`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Measures the install size of a package inside node_modules.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} projectDir
|
|
70
|
+
* @param {string} pkgName
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
function getPackageInstallSize(projectDir, pkgName) {
|
|
74
|
+
const pkgPath = path.join(projectDir, 'node_modules', ...pkgName.split('/'));
|
|
75
|
+
return getDirectorySize(pkgPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Calculates health score, letter grade, and disk savings for an analyzed project.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} analysis - Result from analyzer.analyzeProject
|
|
82
|
+
* @param {object} [options]
|
|
83
|
+
* @param {boolean} [options.roast=false]
|
|
84
|
+
* @returns {object} Score and savings breakdown
|
|
85
|
+
*/
|
|
86
|
+
function calculateScoreAndSavings(analysis, options = {}) {
|
|
87
|
+
const isRoast = Boolean(options.roast);
|
|
88
|
+
const msgBundle = isRoast ? ROAST_MESSAGES : NORMAL_MESSAGES;
|
|
89
|
+
|
|
90
|
+
const unusedCount = analysis.unused.length;
|
|
91
|
+
const missingCount = analysis.missing.length;
|
|
92
|
+
const probablyCount = analysis.probablyUnused.length;
|
|
93
|
+
|
|
94
|
+
// Score deductions:
|
|
95
|
+
// - Missing package: -15 (severe: app will crash at runtime)
|
|
96
|
+
// - Unused package: -10 (wasted bloat and install time)
|
|
97
|
+
// - Probably unused: -5 (minor uncertainty)
|
|
98
|
+
let rawScore = 100 - (missingCount * 15) - (unusedCount * 10) - (probablyCount * 5);
|
|
99
|
+
if (rawScore < 0) rawScore = 0;
|
|
100
|
+
if (rawScore > 100) rawScore = 100;
|
|
101
|
+
|
|
102
|
+
// Letter grades
|
|
103
|
+
let letterGrade = 'A+';
|
|
104
|
+
if (rawScore === 100) {
|
|
105
|
+
letterGrade = 'A+';
|
|
106
|
+
} else if (rawScore >= 90) {
|
|
107
|
+
letterGrade = 'A';
|
|
108
|
+
} else if (rawScore >= 80) {
|
|
109
|
+
letterGrade = 'B';
|
|
110
|
+
} else if (rawScore >= 70) {
|
|
111
|
+
letterGrade = 'C';
|
|
112
|
+
} else if (rawScore >= 60) {
|
|
113
|
+
letterGrade = 'D';
|
|
114
|
+
} else {
|
|
115
|
+
letterGrade = 'F';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const comment = msgBundle.gradeComments[letterGrade];
|
|
119
|
+
|
|
120
|
+
// Calculate size for each unused package
|
|
121
|
+
let totalBytesSaved = 0;
|
|
122
|
+
const sizedUnused = analysis.unused.map((item) => {
|
|
123
|
+
const bytes = getPackageInstallSize(analysis.projectDir, item.name);
|
|
124
|
+
totalBytesSaved += bytes;
|
|
125
|
+
return {
|
|
126
|
+
...item,
|
|
127
|
+
bytes,
|
|
128
|
+
formattedSize: formatBytes(bytes),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Sort unused packages by weight, biggest first!
|
|
133
|
+
sizedUnused.sort((a, b) => b.bytes - a.bytes);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
score: rawScore,
|
|
137
|
+
letterGrade,
|
|
138
|
+
comment,
|
|
139
|
+
totalBytesSaved,
|
|
140
|
+
formattedSpaceSaved: formatBytes(totalBytesSaved),
|
|
141
|
+
sizedUnused,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
calculateScoreAndSavings,
|
|
147
|
+
getDirectorySize,
|
|
148
|
+
formatBytes,
|
|
149
|
+
getPackageInstallSize,
|
|
150
|
+
};
|