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.
@@ -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
+ };
@@ -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
+ };
@@ -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
+ };