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/src/args.js ADDED
@@ -0,0 +1,116 @@
1
+ // src/args.js
2
+ // WHY: We avoid heavy argument parsing libraries (like commander or yargs)
3
+ // to keep helpforbegs lightweight, fast to install, and dependency-free for CLI parsing.
4
+ // Parsing process.argv manually teaches us exactly how Node.js receives command-line inputs.
5
+
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Parses raw command-line arguments (process.argv.slice(2))
10
+ * into an easy-to-use JavaScript object.
11
+ *
12
+ * @param {string[]} rawArgs - e.g. ['scan', '--roast', '--path', './my-app']
13
+ * @returns {object} Parsed command, package target (for `why`), and options
14
+ */
15
+ function parseArgs(rawArgs = process.argv.slice(2)) {
16
+ const flags = {
17
+ json: false,
18
+ noColor: false,
19
+ roast: false,
20
+ targetDir: process.cwd(), // Default to the folder where the user ran the command
21
+ dryRun: false,
22
+ yes: false,
23
+ help: false,
24
+ version: false,
25
+ debug: false,
26
+ isInteractive: true, // Auto-adjusted below for CI / non-TTY
27
+ };
28
+
29
+ const positional = [];
30
+
31
+ for (let i = 0; i < rawArgs.length; i++) {
32
+ const arg = rawArgs[i];
33
+
34
+ if (arg === '--json') {
35
+ flags.json = true;
36
+ } else if (arg === '--no-color') {
37
+ flags.noColor = true;
38
+ } else if (arg === '--roast') {
39
+ flags.roast = true;
40
+ } else if (arg === '--dry-run') {
41
+ flags.dryRun = true;
42
+ } else if (arg === '--yes' || arg === '-y') {
43
+ flags.yes = true;
44
+ } else if (arg === '--help' || arg === '-h') {
45
+ flags.help = true;
46
+ } else if (arg === '--version' || arg === '-v') {
47
+ flags.version = true;
48
+ } else if (arg === '--debug') {
49
+ flags.debug = true;
50
+ } else if (arg === '--path') {
51
+ // The user provided --path <directory>. Look at the next argument.
52
+ const nextArg = rawArgs[i + 1];
53
+ if (nextArg && !nextArg.startsWith('-')) {
54
+ flags.targetDir = path.resolve(process.cwd(), nextArg);
55
+ i++; // Skip the directory string in the next loop iteration
56
+ }
57
+ } else if (arg.startsWith('--path=')) {
58
+ // Support syntax like --path=./subfolder
59
+ const customPath = arg.slice('--path='.length);
60
+ flags.targetDir = path.resolve(process.cwd(), customPath);
61
+ } else if (!arg.startsWith('-')) {
62
+ positional.push(arg);
63
+ }
64
+ }
65
+
66
+ // Determine the primary command:
67
+ // e.g. `helpforbegs` -> 'scan'
68
+ // `helpforbegs scan` -> 'scan'
69
+ // `helpforbegs remove` -> 'remove'
70
+ // `helpforbegs why lodash` -> command: 'why', targetPkg: 'lodash'
71
+ // `helpforbegs score` -> 'score'
72
+ let command = 'scan';
73
+ let targetPkg = null;
74
+
75
+ if (positional.length > 0) {
76
+ const first = positional[0].toLowerCase();
77
+ if (first === 'scan' || first === 'remove' || first === 'score') {
78
+ command = first;
79
+ } else if (first === 'why') {
80
+ command = 'why';
81
+ targetPkg = positional[1] || null; // e.g. 'why express' -> express
82
+ } else {
83
+ // If someone types `helpforbegs my-package` without saying 'why',
84
+ // or an unknown command, we keep 'scan' or treat it safely.
85
+ command = first;
86
+ }
87
+ }
88
+
89
+ // TTY and CI detection:
90
+ // WHY: If the CLI is run inside a script, redirected to a file (`helpforbegs > output.txt`),
91
+ // or executed inside a CI pipeline (like GitHub Actions), process.stdout.isTTY is false.
92
+ // In those environments:
93
+ // 1. We must not emit ANSI escape color codes (they turn into messy symbols in text files).
94
+ // 2. We cannot ask interactive readline questions (there is no human sitting at a keyboard).
95
+ const isCI = Boolean(process.env.CI);
96
+ const isNoColorEnv = Boolean(process.env.NO_COLOR);
97
+ const isNotTTY = !process.stdout.isTTY;
98
+
99
+ if (isCI || isNoColorEnv || isNotTTY || flags.noColor) {
100
+ flags.noColor = true;
101
+ }
102
+
103
+ if (isCI || isNotTTY) {
104
+ flags.isInteractive = false;
105
+ }
106
+
107
+ return {
108
+ command,
109
+ targetPkg,
110
+ flags,
111
+ };
112
+ }
113
+
114
+ module.exports = {
115
+ parseArgs,
116
+ };
@@ -0,0 +1,228 @@
1
+ // src/astParser.js
2
+ // WHY: Regular expressions can be easily fooled by commented-out code
3
+ // or strings containing "require('foo')". Using @babel/parser ensures we
4
+ // inspect the real AST (Abstract Syntax Tree) with 100% syntactic precision.
5
+
6
+ const babelParser = require('@babel/parser');
7
+ const path = require('path');
8
+ const { scanWithRegex, normalizePackageName } = require('./fallbackParser');
9
+
10
+ /**
11
+ * Determines the exact Babel plugins needed based on the file extension.
12
+ *
13
+ * Rule from user:
14
+ * - .ts: ['typescript']
15
+ * - .tsx: ['typescript', 'jsx']
16
+ * - .js / .jsx / .mjs / .cjs: ['jsx']
17
+ *
18
+ * @param {string} ext - e.g. '.tsx'
19
+ * @returns {string[]}
20
+ */
21
+ function getBabelPlugins(ext) {
22
+ const normalizedExt = (ext || '').toLowerCase();
23
+ if (normalizedExt === '.ts') {
24
+ return ['typescript'];
25
+ }
26
+ if (normalizedExt === '.tsx') {
27
+ return ['typescript', 'jsx'];
28
+ }
29
+ return ['jsx'];
30
+ }
31
+
32
+ /**
33
+ * Traverses an AST tree without needing an external walker library.
34
+ *
35
+ * @param {object} node - Current AST node
36
+ * @param {(node: object) => void} visitor - Function called for each node
37
+ */
38
+ function walkAST(node, visitor) {
39
+ if (!node || typeof node !== 'object') {
40
+ return;
41
+ }
42
+
43
+ visitor(node);
44
+
45
+ for (const key of Object.keys(node)) {
46
+ // Avoid traversing parent references or metadata to prevent infinite loops
47
+ if (key === 'loc' || key === 'comments' || key === 'tokens') {
48
+ continue;
49
+ }
50
+ const child = node[key];
51
+ if (Array.isArray(child)) {
52
+ for (const item of child) {
53
+ if (item && typeof item === 'object') {
54
+ walkAST(item, visitor);
55
+ }
56
+ }
57
+ } else if (child && typeof child === 'object') {
58
+ walkAST(child, visitor);
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Safely extracts a string value from an argument node.
65
+ * Handles both plain 'string' literals and non-dynamic template literals like `pkg`.
66
+ *
67
+ * @param {object} argNode
68
+ * @returns {string|null}
69
+ */
70
+ function extractStringArgument(argNode) {
71
+ if (!argNode) return null;
72
+ if (argNode.type === 'StringLiteral') {
73
+ return argNode.value;
74
+ }
75
+ if (argNode.type === 'TemplateLiteral' && argNode.expressions.length === 0 && argNode.quasis.length === 1) {
76
+ return argNode.quasis[0].value.raw;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Parses a code string into AST, extracts all imported/required package names,
83
+ * and falls back to regex if the file has syntax errors.
84
+ *
85
+ * @param {string} code - Source code text
86
+ * @param {string} filePath - Path to the file (used to decide plugins & line numbers)
87
+ * @param {object} [options]
88
+ * @param {Set<string>} [options.knownAliases] - Path aliases to ignore
89
+ * @returns {{ imports: Array<{ packageName: string, rawSpecifier: string, line: number, isTypeOnly: boolean }>, warning: string|null }}
90
+ */
91
+ function parseImports(code, filePath, options = {}) {
92
+ const ext = path.extname(filePath);
93
+ const plugins = getBabelPlugins(ext);
94
+ const knownAliases = options.knownAliases || new Set();
95
+ const rawImports = [];
96
+ let warning = null;
97
+
98
+ try {
99
+ const ast = babelParser.parse(code, {
100
+ sourceType: 'unambiguous', // Automatically detects CommonJS vs ES Modules
101
+ plugins: plugins,
102
+ errorRecovery: true, // Recovers from non-fatal syntax errors
103
+ attachComment: false,
104
+ });
105
+
106
+ walkAST(ast, (node) => {
107
+ // 1. Static ES Module Imports: `import foo from 'pkg'` or `import 'pkg'`
108
+ if (node.type === 'ImportDeclaration') {
109
+ if (node.source && typeof node.source.value === 'string') {
110
+ const isTypeOnly = node.importKind === 'type';
111
+ rawImports.push({
112
+ rawSpecifier: node.source.value,
113
+ line: node.loc ? node.loc.start.line : 1,
114
+ isTypeOnly,
115
+ });
116
+ }
117
+ }
118
+
119
+ // 2. ES Module Re-exports: `export { x } from 'pkg'` or `export * from 'pkg'`
120
+ else if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') {
121
+ if (node.source && typeof node.source.value === 'string') {
122
+ const isTypeOnly = node.exportKind === 'type';
123
+ rawImports.push({
124
+ rawSpecifier: node.source.value,
125
+ line: node.loc ? node.loc.start.line : 1,
126
+ isTypeOnly,
127
+ });
128
+ }
129
+ }
130
+
131
+ // 3. CommonJS `require('pkg')`, `require.resolve('pkg')`, and dynamic `import('pkg')`
132
+ else if (node.type === 'CallExpression') {
133
+ // require('pkg')
134
+ if (node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments.length > 0) {
135
+ const specifier = extractStringArgument(node.arguments[0]);
136
+ if (specifier) {
137
+ rawImports.push({
138
+ rawSpecifier: specifier,
139
+ line: node.loc ? node.loc.start.line : 1,
140
+ isTypeOnly: false,
141
+ });
142
+ }
143
+ }
144
+
145
+ // require.resolve('pkg')
146
+ else if (
147
+ node.callee.type === 'MemberExpression' &&
148
+ node.callee.object.type === 'Identifier' &&
149
+ node.callee.object.name === 'require' &&
150
+ node.callee.property.type === 'Identifier' &&
151
+ node.callee.property.name === 'resolve' &&
152
+ node.arguments.length > 0
153
+ ) {
154
+ const specifier = extractStringArgument(node.arguments[0]);
155
+ if (specifier) {
156
+ rawImports.push({
157
+ rawSpecifier: specifier,
158
+ line: node.loc ? node.loc.start.line : 1,
159
+ isTypeOnly: false,
160
+ });
161
+ }
162
+ }
163
+
164
+ // Dynamic import('pkg')
165
+ else if (node.callee.type === 'Import' && node.arguments.length > 0) {
166
+ const specifier = extractStringArgument(node.arguments[0]);
167
+ if (specifier) {
168
+ rawImports.push({
169
+ rawSpecifier: specifier,
170
+ line: node.loc ? node.loc.start.line : 1,
171
+ isTypeOnly: false,
172
+ });
173
+ }
174
+ }
175
+ }
176
+
177
+ // 4. TypeScript `import foo = require('pkg')`
178
+ else if (node.type === 'TSImportEqualsDeclaration') {
179
+ if (
180
+ node.moduleReference &&
181
+ node.moduleReference.type === 'TSExternalModuleReference' &&
182
+ node.moduleReference.expression &&
183
+ typeof node.moduleReference.expression.value === 'string'
184
+ ) {
185
+ rawImports.push({
186
+ rawSpecifier: node.moduleReference.expression.value,
187
+ line: node.loc ? node.loc.start.line : 1,
188
+ isTypeOnly: false,
189
+ });
190
+ }
191
+ }
192
+ });
193
+ } catch (err) {
194
+ // If AST parsing fails, use our safe fallback regex scanner for this file
195
+ warning = `Could not fully read ${filePath}`;
196
+ const fallbackResults = scanWithRegex(code, filePath, { knownAliases });
197
+ return {
198
+ imports: fallbackResults,
199
+ warning,
200
+ };
201
+ }
202
+
203
+ // Normalize all raw specifiers into npm package names
204
+ const normalizedImports = [];
205
+ for (const item of rawImports) {
206
+ const packageName = normalizePackageName(item.rawSpecifier, knownAliases);
207
+ if (packageName) {
208
+ normalizedImports.push({
209
+ packageName,
210
+ rawSpecifier: item.rawSpecifier,
211
+ line: item.line,
212
+ isTypeOnly: item.isTypeOnly,
213
+ });
214
+ }
215
+ }
216
+
217
+ return {
218
+ imports: normalizedImports,
219
+ warning,
220
+ };
221
+ }
222
+
223
+ module.exports = {
224
+ parseImports,
225
+ getBabelPlugins,
226
+ normalizePackageName,
227
+ walkAST,
228
+ };
@@ -0,0 +1,167 @@
1
+ // src/binMapper.js
2
+ // WHY: When a user installs CLI tools like "rimraf", "nodemon", or "eslint",
3
+ // they don't `require('rimraf')` in code. Instead, they write `"clean": "rimraf dist"`
4
+ // in package.json scripts.
5
+ // By reading each installed package's "bin" field in node_modules, we discover
6
+ // what terminal commands it provides, and we can check if the user runs those commands in their scripts.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ // Standard fallback command mappings for common development tools.
12
+ // WHY: If a user hasn't run `npm install` yet, node_modules is missing.
13
+ // Having built-in knowledge of standard tool commands (e.g. `tsc` comes from `typescript`)
14
+ // ensures we still recognize script usage and avoid false positives!
15
+ const DEFAULT_KNOWN_BINS = new Map([
16
+ ['tsc', 'typescript'],
17
+ ['eslint', 'eslint'],
18
+ ['prettier', 'prettier'],
19
+ ['jest', 'jest'],
20
+ ['nodemon', 'nodemon'],
21
+ ['vite', 'vite'],
22
+ ['webpack', 'webpack'],
23
+ ['rimraf', 'rimraf'],
24
+ ['cross-env', 'cross-env'],
25
+ ['ts-node', 'ts-node'],
26
+ ['tsx', 'tsx'],
27
+ ['babel', '@babel/cli'],
28
+ ['mocha', 'mocha'],
29
+ ['vitest', 'vitest'],
30
+ ['concurrently', 'concurrently'],
31
+ ['npm-run-all', 'npm-run-all'],
32
+ ]);
33
+
34
+ /**
35
+ * Discovers CLI binary commands provided by all installed packages in node_modules.
36
+ *
37
+ * @param {string} projectDir - Root folder containing package.json and node_modules
38
+ * @returns {{ binToPkg: Map<string, string>, pkgToBins: Map<string, string[]>, missingNodeModules: boolean }}
39
+ */
40
+ function mapInstalledBins(projectDir) {
41
+ // Pre-seed with known standard binaries as a safety baseline
42
+ const binToPkg = new Map(DEFAULT_KNOWN_BINS);
43
+ const pkgToBins = new Map();
44
+ const nodeModulesPath = path.join(projectDir, 'node_modules');
45
+
46
+ if (!fs.existsSync(nodeModulesPath)) {
47
+ // If node_modules hasn't been installed yet, don't crash!
48
+ // Simply report that node_modules is missing so the analyzer can inform the user.
49
+ return {
50
+ binToPkg,
51
+ pkgToBins,
52
+ missingNodeModules: true,
53
+ };
54
+ }
55
+
56
+ function readPkgJson(pkgName, pkgJsonPath) {
57
+ try {
58
+ if (fs.existsSync(pkgJsonPath)) {
59
+ const content = fs.readFileSync(pkgJsonPath, 'utf8');
60
+ const pkgData = JSON.parse(content);
61
+
62
+ if (pkgData.bin) {
63
+ const bins = [];
64
+ if (typeof pkgData.bin === 'string') {
65
+ // If "bin" is a string, the command name defaults to the package name
66
+ // (or unscoped name for scoped packages)
67
+ const defaultBinName = pkgName.startsWith('@') ? pkgName.split('/')[1] : pkgName;
68
+ binToPkg.set(defaultBinName, pkgName);
69
+ bins.push(defaultBinName);
70
+ } else if (typeof pkgData.bin === 'object' && pkgData.bin !== null) {
71
+ for (const binName of Object.keys(pkgData.bin)) {
72
+ binToPkg.set(binName, pkgName);
73
+ bins.push(binName);
74
+ }
75
+ }
76
+
77
+ if (bins.length > 0) {
78
+ pkgToBins.set(pkgName, bins);
79
+ }
80
+ }
81
+ }
82
+ } catch {
83
+ // Ignore unparseable or corrupted package.json files inside node_modules
84
+ }
85
+ }
86
+
87
+ try {
88
+ const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true });
89
+
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory()) continue;
92
+
93
+ if (entry.name.startsWith('@')) {
94
+ // Handle scoped packages like @babel/cli inside node_modules/@babel/
95
+ const scopePath = path.join(nodeModulesPath, entry.name);
96
+ try {
97
+ const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true });
98
+ for (const scopedEntry of scopedEntries) {
99
+ if (scopedEntry.isDirectory()) {
100
+ const fullPkgName = `${entry.name}/${scopedEntry.name}`;
101
+ const pkgJsonPath = path.join(scopePath, scopedEntry.name, 'package.json');
102
+ readPkgJson(fullPkgName, pkgJsonPath);
103
+ }
104
+ }
105
+ } catch {
106
+ // Skip unreadable scope folders
107
+ }
108
+ } else if (!entry.name.startsWith('.')) {
109
+ // Standard non-scoped package
110
+ const pkgJsonPath = path.join(nodeModulesPath, entry.name, 'package.json');
111
+ readPkgJson(entry.name, pkgJsonPath);
112
+ }
113
+ }
114
+ } catch {
115
+ return {
116
+ binToPkg,
117
+ pkgToBins,
118
+ missingNodeModules: false,
119
+ };
120
+ }
121
+
122
+ return {
123
+ binToPkg,
124
+ pkgToBins,
125
+ missingNodeModules: false,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Checks all package.json scripts to see which packages are executed via CLI commands.
131
+ *
132
+ * @param {Record<string, string>} scripts - The "scripts" object from package.json
133
+ * @param {Map<string, string>} binToPkg - Map of commandName -> packageName
134
+ * @returns {Map<string, Array<{ scriptName: string, command: string }>>} - Map of packageName -> where it was used
135
+ */
136
+ function findBinUsageInScripts(scripts = {}, binToPkg = new Map()) {
137
+ const usageByPkg = new Map();
138
+
139
+ for (const [scriptName, scriptCmd] of Object.entries(scripts)) {
140
+ if (typeof scriptCmd !== 'string') continue;
141
+
142
+ // Check each known binary name to see if it is invoked as a word in the script
143
+ for (const [binName, pkgName] of binToPkg.entries()) {
144
+ // Escape regex special chars in command name
145
+ const escapedBin = binName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
146
+ // Match the command as a standalone word or at the start of a command pipeline
147
+ const wordRegex = new RegExp(`(^|[;&|\\s])${escapedBin}(\\s|$|[;&|])`);
148
+
149
+ if (wordRegex.test(scriptCmd)) {
150
+ if (!usageByPkg.has(pkgName)) {
151
+ usageByPkg.set(pkgName, []);
152
+ }
153
+ usageByPkg.get(pkgName).push({
154
+ scriptName,
155
+ command: binName,
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ return usageByPkg;
162
+ }
163
+
164
+ module.exports = {
165
+ mapInstalledBins,
166
+ findBinUsageInScripts,
167
+ };