From 50390d8b42959fb881517f84cdfb986278d31c14 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 15:49:39 -0400 Subject: [PATCH 01/38] chore: update package-lock --- package-lock.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3930e0d..bae785e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "karpeslop", - "version": "1.0.8", + "version": "1.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.8", + "version": "1.0.20", "license": "MIT", "dependencies": { "glob": "^11.0.0", "tsx": "^4.19.1" }, "bin": { - "karpeslop": "karpeslop-cli.js" + "karpeslop": "karpeslop-bin.js" }, "devDependencies": { "@babel/cli": "^7.28.3", @@ -119,7 +119,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1169,7 +1168,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", From a6e685307a76827e61e9aa297fd7541c61d800f4 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 15:49:39 -0400 Subject: [PATCH 02/38] 1.0.21 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index bae785e..90064da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "karpeslop", - "version": "1.0.20", + "version": "1.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.20", + "version": "1.0.21", "license": "MIT", "dependencies": { "glob": "^11.0.0", diff --git a/package.json b/package.json index d04f151..54a0293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.20", + "version": "1.0.21", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "bin": { @@ -62,4 +62,4 @@ "@types/node": "^24.10.1", "typescript": "^5.9.3" } -} \ No newline at end of file +} From 09126900cdc6be2b819b60fe460874c44488d84a Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 15:56:06 -0400 Subject: [PATCH 03/38] feat: add fresh_package_version detection to flag npm packages updated less than 7 days ago - Add minPackageAgeDays config option (default: 7 days) - Add analyzePackageVersions method to parse package.json/package-lock.json - Add getNpmPackageAge helper to fetch version publish date from npm registry - Add npmPackageCache to cache npm lookups and avoid repeated API calls - Update detect and analyzeFile to be async for npm lookups --- ai-slop-detector.ts | 100 ++++++++++++++++++++++++++++++++++++++++++- karpeslop-bin.js | 102 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 4 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index c3af2f4..a0e9983 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -68,6 +68,7 @@ interface KarpeSlopConfig { ignorePaths?: string[]; severityOverrides?: Record; blockOnCritical?: boolean; + minPackageAgeDays?: number; } class AISlopDetector { @@ -306,6 +307,7 @@ class AISlopDetector { private config: KarpeSlopConfig = {}; private customIgnorePaths: string[] = []; + private npmPackageCache: Map = new Map(); constructor(private rootDir: string) { this.loadConfig(); @@ -458,7 +460,7 @@ class AISlopDetector { // 2. Analyze each file for AI Slop patterns for (const file of filesToAnalyze) { - this.analyzeFile(file, quiet); + await this.analyzeFile(file, quiet); } // 3. Report findings @@ -629,7 +631,7 @@ class AISlopDetector { /** * Analyze a single file for AI Slop patterns */ - private analyzeFile(filePath: string, quiet: boolean = false) { + private async analyzeFile(filePath: string, quiet: boolean = false) { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); @@ -853,6 +855,11 @@ class AISlopDetector { this.analyzeComplexNestedConditionals(filePath, lines, i, lineNumber, line); } + + // Special handling for package.json and package-lock.json to detect fresh package versions + if (filePath.endsWith('package.json') || filePath.endsWith('package-lock.json')) { + this.analyzePackageVersions(filePath, content); + } } /** @@ -996,6 +1003,95 @@ class AISlopDetector { return inCatchBlock; } + /** + * Analyze package.json or package-lock.json for fresh (unstable) package versions + * Flags packages updated less than minPackageAgeDays ago (default 7) + */ + private async analyzePackageVersions(filePath: string, content: string): Promise { + const minAgeDays = this.config.minPackageAgeDays ?? 7; + const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + + let packageData: Record = {}; + + try { + const pkg = JSON.parse(content); + + if (filePath.endsWith('package-lock.json')) { + if (pkg.packages) { + for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { + if (pkgPath === '' || pkgPath === 'node_modules/') continue; + const name = (pkgInfo as Record).name as string; + const version = (pkgInfo as Record).version as string; + if (name && version) { + packageData[name] = version; + } + } + } + } else if (pkg.dependencies) { + packageData = { ...pkg.dependencies }; + } + } catch { + return; + } + + for (const [pkgName, version] of Object.entries(packageData)) { + if (version.startsWith('^') || version.startsWith('~')) { + const actualVersion = version.slice(1); + const ageInfo = await this.getNpmPackageAge(pkgName, actualVersion); + if (ageInfo && ageInfo.ageMs !== null && ageInfo.ageMs < minAgeMs) { + const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); + this.issues.push({ + type: 'fresh_package_version', + file: filePath, + line: 1, + column: 1, + code: `"${pkgName}": "${version}"`, + message: `Package '${pkgName}' v${actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + severity: 'medium' + }); + } + } + } + } + + /** + * Get npm package version age info with caching + */ + private async getNpmPackageAge(pkgName: string, version: string): Promise<{ version: string; time: string; ageMs: number } | null> { + const now = Date.now(); + + const cached = this.npmPackageCache.get(pkgName); + if (cached) { + const found = cached.find(v => v.version === version); + if (found) { + const publishTime = new Date(found.time).getTime(); + return { version, time: found.time, ageMs: now - publishTime }; + } + } + + try { + const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; + const response = await fetch(url); + if (!response.ok) return null; + + const data = (await response.json()) as { time: Record }; + const versionTime = data.time?.[version]; + if (!versionTime) return null; + + const publishTime = new Date(versionTime).getTime(); + + if (!this.npmPackageCache.has(pkgName)) { + this.npmPackageCache.set(pkgName, []); + } + this.npmPackageCache.get(pkgName)!.push({ time: versionTime, version }); + + return { version, time: versionTime, ageMs: now - publishTime }; + } catch { + return null; + } + } + /** * Generate a detailed report of findings */ diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 078ce25..d983a65 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -224,6 +224,7 @@ class AISlopDetector { }]; config = {}; customIgnorePaths = []; + npmPackageCache = new Map(); constructor(rootDir) { this.rootDir = rootDir; this.loadConfig(); @@ -365,7 +366,7 @@ class AISlopDetector { // 2. Analyze each file for AI Slop patterns for (const file of filesToAnalyze) { - this.analyzeFile(file, quiet); + await this.analyzeFile(file, quiet); } // 3. Report findings @@ -516,7 +517,7 @@ class AISlopDetector { /** * Analyze a single file for AI Slop patterns */ - analyzeFile(filePath, quiet = false) { + async analyzeFile(filePath, quiet = false) { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); @@ -713,6 +714,11 @@ class AISlopDetector { // Now handle complex nested conditionals separately with improved logic this.analyzeComplexNestedConditionals(filePath, lines, i, lineNumber, line); } + + // Special handling for package.json and package-lock.json to detect fresh package versions + if (filePath.endsWith('package.json') || filePath.endsWith('package-lock.json')) { + this.analyzePackageVersions(filePath, content); + } } /** @@ -847,6 +853,98 @@ class AISlopDetector { return inCatchBlock; } + /** + * Analyze package.json or package-lock.json for fresh (unstable) package versions + * Flags packages updated less than minPackageAgeDays ago (default 7) + */ + async analyzePackageVersions(filePath, content) { + const minAgeDays = this.config.minPackageAgeDays ?? 7; + const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + let packageData = {}; + try { + const pkg = JSON.parse(content); + if (filePath.endsWith('package-lock.json')) { + if (pkg.packages) { + for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { + if (pkgPath === '' || pkgPath === 'node_modules/') continue; + const name = pkgInfo.name; + const version = pkgInfo.version; + if (name && version) { + packageData[name] = version; + } + } + } + } else if (pkg.dependencies) { + packageData = { + ...pkg.dependencies + }; + } + } catch { + return; + } + for (const [pkgName, version] of Object.entries(packageData)) { + if (version.startsWith('^') || version.startsWith('~')) { + const actualVersion = version.slice(1); + const ageInfo = await this.getNpmPackageAge(pkgName, actualVersion); + if (ageInfo && ageInfo.ageMs !== null && ageInfo.ageMs < minAgeMs) { + const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); + this.issues.push({ + type: 'fresh_package_version', + file: filePath, + line: 1, + column: 1, + code: `"${pkgName}": "${version}"`, + message: `Package '${pkgName}' v${actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + severity: 'medium' + }); + } + } + } + } + + /** + * Get npm package version age info with caching + */ + async getNpmPackageAge(pkgName, version) { + const now = Date.now(); + const cached = this.npmPackageCache.get(pkgName); + if (cached) { + const found = cached.find(v => v.version === version); + if (found) { + const publishTime = new Date(found.time).getTime(); + return { + version, + time: found.time, + ageMs: now - publishTime + }; + } + } + try { + const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; + const response = await fetch(url); + if (!response.ok) return null; + const data = await response.json(); + const versionTime = data.time?.[version]; + if (!versionTime) return null; + const publishTime = new Date(versionTime).getTime(); + if (!this.npmPackageCache.has(pkgName)) { + this.npmPackageCache.set(pkgName, []); + } + this.npmPackageCache.get(pkgName).push({ + time: versionTime, + version + }); + return { + version, + time: versionTime, + ageMs: now - publishTime + }; + } catch { + return null; + } + } + /** * Generate a detailed report of findings */ From bc0dc4b2bd693d5e34c582d8f687f3877e99758b Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 15:57:51 -0400 Subject: [PATCH 04/38] 1.0.22 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 90064da..ea8195e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "karpeslop", - "version": "1.0.21", + "version": "1.0.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.21", + "version": "1.0.22", "license": "MIT", "dependencies": { "glob": "^11.0.0", diff --git a/package.json b/package.json index 54a0293..710cb16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.21", + "version": "1.0.22", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "bin": { From 51db9efd5462fc38870ef11c1e14ec4beb82f54b Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 16:01:31 -0400 Subject: [PATCH 05/38] 1.0.23 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 710cb16..e5ba5f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.22", + "version": "1.0.23", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "bin": { From 19c92fa66ab68cfcdeb8996585093d29c920655a Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 16:04:08 -0400 Subject: [PATCH 06/38] fix: use karpeslop-cli.js as bin entry point --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5ba5f0..989e57c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "bin": { - "karpeslop": "karpeslop-bin.js" + "karpeslop": "karpeslop-cli.js" }, "scripts": { "build": "babel ai-slop-detector.ts --out-file karpeslop-bin.js --extensions \".ts,.tsx\"", From ae8b1167b4fd77246cb27624d9457468d3d69427 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 16:04:15 -0400 Subject: [PATCH 07/38] 1.0.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 989e57c..1c95911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.23", + "version": "1.0.24", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "bin": { From 846dacd23adafe9f166ad5f7154843e1333c0fd3 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 16:08:10 -0400 Subject: [PATCH 08/38] docs: add CHANGELOG.md --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 27 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4ca1834 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes will be documented in this file. + +## [1.0.24] - 2026-04-04 + +### Fixed +- **Bin entry point**: `karpeslop-cli.js` is now correctly registered as the CLI binary, fixing `npx karpeslop` execution errors + +### Fixed (from PR #9) +- **isInTryCatchBlock**: Complete rewrite with proper nested depth tracking and catch scope detection +- **overconfident_comment**: Removed incorrect skip logic that was causing false negatives on lines starting with `//` +- **missing_error_handling**: Added word boundary anchors and comment-line skipping +- **todo_comment**: Added word boundary so `BUG` doesn't match inside `DEBUG` +- **unsafe_double_type_assertion**: Added comment-line skip and English phrase detection +- **production_console_log**: Added conditional guard detection to reduce false positives +- **overconfident_comment regex**: Changed from `/\/\/\s*.../` to `/\/\/.*.../` to match "This is obviously wrong" +- **magic_css_value**: Removed leading word boundary from hex color pattern + +### Added +- **fresh_package_version detection**: New rule to flag npm packages in `package.json` or `package-lock.json` that use `^`/`~` with versions published less than 7 days ago. Configurable via `minPackageAgeDays` in `.karpesloprc.json`. + +## [1.0.21] - 2026-04-04 + +### Initial Release +- Initial npm release of KarpeSlop AI slop detector diff --git a/package.json b/package.json index 1c95911..5c7e2d3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "karpeslop-cli.js", "karpeslop.js", "README.md", + "CHANGELOG.md", "package.json", "LICENSE" ], From dc07a70534c06b926c29e8679282bb76cd2a44ca Mon Sep 17 00:00:00 2001 From: Daniel King Date: Sat, 4 Apr 2026 16:08:16 -0400 Subject: [PATCH 09/38] 1.0.25 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c7e2d3..ba36286 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.24", + "version": "1.0.25", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "bin": { From 76a3d6f71413e6e067c95eec31620d079db468b7 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:22:17 -0400 Subject: [PATCH 10/38] feat: add positional path arguments for single-file/directory targeting KarpeSlop had no way to target specific files or directories -- it always globs the entire project. Add [path...] positional arguments that accept files or directories to scan, skipping full-project discovery when provided. - Add targetPaths field and constructor parameter to AISlopDetector - Add resolveTargetPaths() to validate, filter, and expand CLI paths - Update detect() to use targetPaths when provided instead of findAllFiles() - Parse positional args (non-flags) as target paths in runIfMain() - Update --strict to report which file(s) had critical issues when targeting - Update help text with path arguments and examples Closes #14 --- ai-slop-detector.ts | 118 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 22 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index a0e9983..a50a0bb 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -308,9 +308,15 @@ class AISlopDetector { private config: KarpeSlopConfig = {}; private customIgnorePaths: string[] = []; private npmPackageCache: Map = new Map(); + private targetPaths: string[] = []; - constructor(private rootDir: string) { + constructor(private rootDir: string, targetPaths?: string[]) { this.loadConfig(); + if (targetPaths && targetPaths.length > 0) { + this.targetPaths = targetPaths.map(p => + path.isAbsolute(p) ? p : path.resolve(this.rootDir, p) + ); + } } /** @@ -445,18 +451,23 @@ class AISlopDetector { async detect(quiet: boolean = false) { console.log('πŸ” Starting AI Slop detection...\n'); - // 1. Find all TypeScript/JavaScript files - const allFiles = this.findAllFiles(); + let filesToAnalyze: string[]; - // Filter files based on quiet mode (skip non-core files if quiet is true) - const filesToAnalyze = quiet - ? allFiles.filter(file => { - const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); - return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); - }) - : allFiles; + if (this.targetPaths.length > 0) { + filesToAnalyze = this.resolveTargetPaths(); + console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); + } else { + const allFiles = this.findAllFiles(); - console.log(`πŸ“ Found ${allFiles.length} files to analyze (${filesToAnalyze.length} in ${quiet ? 'quiet' : 'full'} mode)\n`); + filesToAnalyze = quiet + ? allFiles.filter(file => { + const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); + return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); + }) + : allFiles; + + console.log(`πŸ“ Found ${allFiles.length} files to analyze (${filesToAnalyze.length} in ${quiet ? 'quiet' : 'full'} mode)\n`); + } // 2. Analyze each file for AI Slop patterns for (const file of filesToAnalyze) { @@ -524,6 +535,53 @@ class AISlopDetector { return [...new Set(allFiles)]; } + /** + * Resolve target paths (files and directories) passed via CLI + * Expands directories into their scannable files, validates files exist and have correct extensions + */ + private resolveTargetPaths(): string[] { + const resolved: string[] = []; + + for (const targetPath of this.targetPaths) { + if (!fs.existsSync(targetPath)) { + console.warn(`⚠️ Target path does not exist, skipping: ${targetPath}`); + continue; + } + + const stat = fs.statSync(targetPath); + + if (stat.isFile()) { + const ext = path.extname(targetPath); + if (this.targetExtensions.includes(ext)) { + resolved.push(targetPath); + } else { + console.warn(`⚠️ Target file has unsupported extension (${ext}), skipping: ${targetPath}`); + } + } else if (stat.isDirectory()) { + for (const ext of this.targetExtensions) { + const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); + const files = glob.sync(pattern, { + ignore: [ + 'node_modules/**', + '.next/**', + 'dist/**', + 'build/**', + 'coverage/**', + 'generated/**', + '.vercel/**', + '.git/**', + '**/*.d.ts', + ...this.customIgnorePaths + ] + }); + resolved.push(...files); + } + } + } + + return [...new Set(resolved)]; + } + /** * Check if a fetch call is properly handled with try/catch or .catch() */ @@ -1432,15 +1490,23 @@ class AISlopDetector { // Run the detector if this script is executed directly async function runIfMain() { const rootDir = process.cwd(); - const detector = new AISlopDetector(rootDir); // Parse command line arguments const args = process.argv.slice(2); + // Separate flags from positional path arguments + const flagArgs = args.filter(a => a.startsWith('-')); + const targetPaths = args.filter(a => !a.startsWith('-')); + + const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); + // Check for help options first - if (args.includes('--help') || args.includes('-h') || args.includes('/?')) { + if (flagArgs.includes('--help') || flagArgs.includes('-h') || flagArgs.includes('/?')) { console.log(` -Usage: karpeslop [options] +Usage: karpeslop [options] [path...] + +Arguments: + path... File or directory path(s) to scan (scans all files if omitted) Options: --help, -h Show this help message @@ -1454,10 +1520,13 @@ Exit Codes: 2 - Critical issues found (--strict mode only) Examples: - karpeslop # Scan all files in current directory - karpeslop --quiet # Scan only core application files - karpeslop --strict # Block on critical issues (hallucinations) - karpeslop --help # Show this help + karpeslop # Scan all files in current directory + karpeslop src/app.ts # Scan a single file + karpeslop src/lib/ # Scan a directory + karpeslop src/app.ts src/lib/ # Scan specific paths + karpeslop --quiet # Scan only core application files + karpeslop --strict src/app.ts # Block on critical issues in a specific file + karpeslop --help # Show this help The tool detects the three axes of AI slop: 1. Information Utility (Noise) - Comments, boilerplate, etc. @@ -1468,7 +1537,7 @@ The tool detects the three axes of AI slop: } // Check for version options - if (args.includes('--version') || args.includes('-v')) { + if (flagArgs.includes('--version') || flagArgs.includes('-v')) { // Try to get version from package.json try { const packagePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'); @@ -1480,8 +1549,8 @@ The tool detects the three axes of AI slop: process.exit(0); } - const quiet = args.includes('--quiet') || args.includes('-q'); - const strict = args.includes('--strict') || args.includes('-s') || !!detector.getConfig().blockOnCritical; + const quiet = flagArgs.includes('--quiet') || flagArgs.includes('-q'); + const strict = flagArgs.includes('--strict') || flagArgs.includes('-s') || !!detector.getConfig().blockOnCritical; try { const issues = await detector.detect(quiet); @@ -1492,7 +1561,12 @@ The tool detects the three axes of AI slop: // In strict mode, exit with code 2 if there are any critical issues (hallucinations) const criticalIssues = issues.filter(i => i.severity === 'critical'); if (strict && criticalIssues.length > 0) { - console.log(`\n❌ STRICT MODE: ${criticalIssues.length} CRITICAL issue(s) found. Blocking.`); + if (targetPaths.length > 0) { + const criticalFiles = [...new Set(criticalIssues.map(i => path.relative(rootDir, i.file)))]; + console.log(`\n❌ STRICT MODE: ${criticalIssues.length} CRITICAL issue(s) found in: ${criticalFiles.join(', ')}`); + } else { + console.log(`\n❌ STRICT MODE: ${criticalIssues.length} CRITICAL issue(s) found. Blocking.`); + } process.exit(2); } From 692da31c6da57e17dff2cbd8d324a5b04bd8dd02 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:24:04 -0400 Subject: [PATCH 11/38] fix: share glob ignore list between findAllFiles and resolveTargetPaths, add -- separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolveTargetPaths() method had an incomplete ignore list missing '**/.*', '**/types/**', self-exclusion patterns, and the post-glob filter. This meant targeting a directory would include .git/, coverage/, and the detector itself β€” files excluded in full-project scan mode. Extract getGlobIgnorePatterns() and isExcludedPath() as shared methods used by both findAllFiles() and resolveTargetPaths(). Also add -- separator support so file paths starting with - aren't misclassified as flags. --- ai-slop-detector.ts | 119 ++++++++++++++++++++++---------------------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index a50a0bb..3fd9944 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -480,6 +480,47 @@ class AISlopDetector { return this.issues; } + private getGlobIgnorePatterns(): string[] { + return [ + 'node_modules/**', + '.next/**', + 'dist/**', + 'build/**', + 'coverage/**', + 'generated/**', + '.vercel/**', + '.git/**', + '**/types/**', + '**/node_modules/**', + '**/.*', + '**/*.d.ts', + '**/coverage/**', + '**/out/**', + '**/temp/**', + 'scripts/ai-slop-detector.ts', + 'ai-slop-detector.ts', + 'improved-ai-slop-detector.ts', + ...this.customIgnorePaths + ]; + } + + private isExcludedPath(filePath: string): boolean { + const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + return !( + !relativePath.includes('generated/') && + !relativePath.includes('/generated') && + !relativePath.startsWith('generated/') && + !relativePath.includes('coverage/') && + !relativePath.includes('.next/') && + !relativePath.includes('node_modules/') && + !relativePath.includes('dist/') && + !relativePath.includes('build/') && + !relativePath.includes('.git/') && + !relativePath.includes('out/') && + !relativePath.includes('temp/') + ); + } + /** * Find all TypeScript/JavaScript files in the project */ @@ -488,46 +529,8 @@ class AISlopDetector { for (const ext of this.targetExtensions) { const pattern = path.join(this.rootDir, `**/*${ext}`).replace(/\\/g, '/'); - const files = glob.sync(pattern, { - ignore: [ - 'node_modules/**', - '.next/**', - 'dist/**', - 'build/**', - 'coverage/**', - 'generated/**', // Prisma generated files - '.vercel/**', // Vercel build files - '.git/**', // Git files - '**/types/**', // Exclude type definition files - '**/node_modules/**', - '**/.*', // Hidden directories like .git (but not .tsx files) - '**/*.d.ts', // Don't scan declaration files - '**/coverage/**', // Coverage reports - '**/out/**', // Next.js output directory - '**/temp/**', // Temporary files - 'scripts/ai-slop-detector.ts', // Exclude the detector script itself to avoid false positives - 'ai-slop-detector.ts', // Also exclude when in root directory - 'improved-ai-slop-detector.ts', // Exclude the improved detector script to avoid false positives - ...this.customIgnorePaths - ] - }); - - // Additional filtering to remove any generated files that may have slipped through - const filteredFiles = files.filter(file => { - const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); - return !relativePath.includes('generated/') && - !relativePath.includes('/generated') && - !relativePath.startsWith('generated/') && - !relativePath.includes('coverage/') && - !relativePath.includes('.next/') && - !relativePath.includes('node_modules/') && - !relativePath.includes('dist/') && - !relativePath.includes('build/') && - !relativePath.includes('.git/') && - !relativePath.includes('out/') && - !relativePath.includes('temp/'); - }); - + const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); + const filteredFiles = files.filter(file => !this.isExcludedPath(file)); allFiles.push(...filteredFiles); } @@ -560,21 +563,9 @@ class AISlopDetector { } else if (stat.isDirectory()) { for (const ext of this.targetExtensions) { const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); - const files = glob.sync(pattern, { - ignore: [ - 'node_modules/**', - '.next/**', - 'dist/**', - 'build/**', - 'coverage/**', - 'generated/**', - '.vercel/**', - '.git/**', - '**/*.d.ts', - ...this.customIgnorePaths - ] - }); - resolved.push(...files); + const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); + const filteredFiles = files.filter(file => !this.isExcludedPath(file)); + resolved.push(...filteredFiles); } } } @@ -1492,11 +1483,19 @@ async function runIfMain() { const rootDir = process.cwd(); // Parse command line arguments + // Support -- separator: everything after -- is treated as a path, even if it starts with - const args = process.argv.slice(2); - - // Separate flags from positional path arguments - const flagArgs = args.filter(a => a.startsWith('-')); - const targetPaths = args.filter(a => !a.startsWith('-')); + const doubleDashIdx = args.indexOf('--'); + let flagArgs: string[]; + let targetPaths: string[]; + + if (doubleDashIdx !== -1) { + flagArgs = args.slice(0, doubleDashIdx); + targetPaths = args.slice(doubleDashIdx + 1); + } else { + flagArgs = args.filter(a => a.startsWith('-')); + targetPaths = args.filter(a => !a.startsWith('-')); + } const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); @@ -1513,6 +1512,7 @@ Options: --quiet, -q Run in quiet mode (only scan core app files) --strict, -s Exit with code 2 if critical issues (hallucinations) are found --version, -v Show version information + -- End of flags; treat remaining args as paths (even if they start with -) Exit Codes: 0 - No issues found @@ -1526,6 +1526,7 @@ Examples: karpeslop src/app.ts src/lib/ # Scan specific paths karpeslop --quiet # Scan only core application files karpeslop --strict src/app.ts # Block on critical issues in a specific file + karpeslop -- -dash-file.ts # Scan a file whose name starts with - karpeslop --help # Show this help The tool detects the three axes of AI slop: From 1b30e81f005dc6b796dcbddb5ae2ca9021fb23d8 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:27:09 -0400 Subject: [PATCH 12/38] fix: simplify isExcludedPath with De Morgan's, add missing exclusion patterns Convert double-negation !(a && b) to positive form (a || b) for readability. Add checks for dotfiles, /types/, .d.ts, and self- exclusion patterns that were in getGlobIgnorePatterns but missing from isExcludedPath. --- ai-slop-detector.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 3fd9944..873515f 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -506,19 +506,22 @@ class AISlopDetector { private isExcludedPath(filePath: string): boolean { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - return !( - !relativePath.includes('generated/') && - !relativePath.includes('/generated') && - !relativePath.startsWith('generated/') && - !relativePath.includes('coverage/') && - !relativePath.includes('.next/') && - !relativePath.includes('node_modules/') && - !relativePath.includes('dist/') && - !relativePath.includes('build/') && - !relativePath.includes('.git/') && - !relativePath.includes('out/') && - !relativePath.includes('temp/') - ); + return relativePath.includes('generated/') || + relativePath.includes('/generated') || + relativePath.startsWith('generated/') || + relativePath.includes('coverage/') || + relativePath.includes('.next/') || + relativePath.includes('node_modules/') || + relativePath.includes('dist/') || + relativePath.includes('build/') || + relativePath.includes('.git/') || + relativePath.includes('out/') || + relativePath.includes('temp/') || + relativePath.startsWith('.') || + relativePath.includes('/types/') || + relativePath.endsWith('.d.ts') || + relativePath.endsWith('ai-slop-detector.ts') || + relativePath.endsWith('improved-ai-slop-detector.ts'); } /** From 82c8e32d38a6f26759ff1eb54c4b42490b23bec1 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:29:05 -0400 Subject: [PATCH 13/38] fix: dotfile exclusion covers nested paths, add isExcludedPath check for explicit file args - Replace startsWith('.') with segment-based check so nested dotfiles like src/.env are properly excluded - Add startsWith('types/') to match root-level types/ directory - Run isExcludedPath on explicitly passed file paths in resolveTargetPaths so excluded files are skipped with a warning rather than silently scanned --- ai-slop-detector.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 873515f..b4cf791 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -517,8 +517,9 @@ class AISlopDetector { relativePath.includes('.git/') || relativePath.includes('out/') || relativePath.includes('temp/') || - relativePath.startsWith('.') || + relativePath.split('/').some(segment => segment.startsWith('.')) || relativePath.includes('/types/') || + relativePath.startsWith('types/') || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); @@ -558,7 +559,9 @@ class AISlopDetector { if (stat.isFile()) { const ext = path.extname(targetPath); - if (this.targetExtensions.includes(ext)) { + if (this.isExcludedPath(targetPath)) { + console.warn(`⚠️ Target file is in an excluded path, skipping: ${targetPath}`); + } else if (this.targetExtensions.includes(ext)) { resolved.push(targetPath); } else { console.warn(`⚠️ Target file has unsupported extension (${ext}), skipping: ${targetPath}`); From f4a15cff868fb0b50be14d872a75afdc9e4e86d5 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:32:18 -0400 Subject: [PATCH 14/38] fix: add includes('types/') to match generated/ exclusion pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the three-check pattern used for generated/ β€” trailing slash variant catches types/ as a directory anywhere in the path, while the prefix-only and contains variants cover segment-edge cases. --- ai-slop-detector.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index b4cf791..389bfd1 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -520,6 +520,7 @@ class AISlopDetector { relativePath.split('/').some(segment => segment.startsWith('.')) || relativePath.includes('/types/') || relativePath.startsWith('types/') || + relativePath.includes('types/') || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); From 7db3f3f787abe75a4249dfa09b9454215bfc3e01 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:37:28 -0400 Subject: [PATCH 15/38] fix: use segment-based check for types/ exclusion to avoid substring false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit includes('types/') is a substring match β€” it would match subtypes/, mytypes/, and any path where 'types/' appears as a substring of a segment. Use split('/').includes('types') so only actual segments named 'types' are excluded. --- ai-slop-detector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 389bfd1..947c8a1 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -520,7 +520,7 @@ class AISlopDetector { relativePath.split('/').some(segment => segment.startsWith('.')) || relativePath.includes('/types/') || relativePath.startsWith('types/') || - relativePath.includes('types/') || + relativePath.split('/').includes('types') || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); From a390199733841c708e600cee43eff6eee844ec28 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:38:59 -0400 Subject: [PATCH 16/38] fix: remove now-redundant types/ substring checks After switching to split('/').includes('types') in 7db3f3f, the older includes('/types/') and startsWith('types/') lines were subsumed by the segment-based check. Remove them to keep the exclusion list clean. --- ai-slop-detector.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 947c8a1..2a33e6a 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -518,8 +518,6 @@ class AISlopDetector { relativePath.includes('out/') || relativePath.includes('temp/') || relativePath.split('/').some(segment => segment.startsWith('.')) || - relativePath.includes('/types/') || - relativePath.startsWith('types/') || relativePath.split('/').includes('types') || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || From 6f3f093d51eaa8d93a5ce85d76ddf90eec1cdd7a Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:47:29 -0400 Subject: [PATCH 17/38] fix: address PR #15 review findings - Add missing await on analyzePackageVersions call so fresh_package_version issues aren't dropped (fire-and-forget promise) - Lockfile branch now correctly checks all package versions (not just ^/~ ranges) since package-lock stores exact semver - Union dependencies/devDependencies/peerDependencies/optionalDependencies in package.json check instead of dependencies only - Skip nested node_modules entries when scanning package-lock to avoid transitive double-counting - package.json/package-lock.json now included in --quiet mode so fresh version warnings still fire - Add parseVersionRange helper to centralize version-range handling - Fix package-lock.json bin entry to point at karpeslop-cli.js (matching package.json) and bump version to 1.0.25 --- ai-slop-detector.ts | 58 ++++++++++++++++++++++++++++++--------------- package-lock.json | 6 ++--- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 2a33e6a..71ea9a3 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -71,6 +71,17 @@ interface KarpeSlopConfig { minPackageAgeDays?: number; } +/** + * Parse a semver range string (e.g. "^1.2.3", "~1.2.3", "1.2.3") into the + * concrete version and whether the original was a range. + */ +function parseVersionRange(version: string): { actualVersion: string; isRange: boolean } { + if (version.startsWith('^') || version.startsWith('~')) { + return { actualVersion: version.slice(1), isRange: true }; + } + return { actualVersion: version, isRange: false }; +} + class AISlopDetector { private issues: AISlopIssue[] = []; private targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -462,6 +473,8 @@ class AISlopDetector { filesToAnalyze = quiet ? allFiles.filter(file => { const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); + const base = path.basename(file); + if (base === 'package.json' || base === 'package-lock.json') return true; return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); }) : allFiles; @@ -912,7 +925,7 @@ class AISlopDetector { // Special handling for package.json and package-lock.json to detect fresh package versions if (filePath.endsWith('package.json') || filePath.endsWith('package-lock.json')) { - this.analyzePackageVersions(filePath, content); + await this.analyzePackageVersions(filePath, content); } } @@ -1064,7 +1077,6 @@ class AISlopDetector { private async analyzePackageVersions(filePath: string, content: string): Promise { const minAgeDays = this.config.minPackageAgeDays ?? 7; const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; - const now = Date.now(); let packageData: Record = {}; @@ -1075,6 +1087,8 @@ class AISlopDetector { if (pkg.packages) { for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { if (pkgPath === '' || pkgPath === 'node_modules/') continue; + // Skip nested node_modules entries to avoid transitive double-counting + if (pkgPath.split('node_modules/').length > 2) continue; const name = (pkgInfo as Record).name as string; const version = (pkgInfo as Record).version as string; if (name && version) { @@ -1082,29 +1096,35 @@ class AISlopDetector { } } } - } else if (pkg.dependencies) { - packageData = { ...pkg.dependencies }; + } else if (typeof pkg === 'object' && pkg !== null) { + const p = pkg as Record | undefined>; + for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + if (p[key]) { + Object.assign(packageData, p[key]); + } + } } } catch { return; } for (const [pkgName, version] of Object.entries(packageData)) { - if (version.startsWith('^') || version.startsWith('~')) { - const actualVersion = version.slice(1); - const ageInfo = await this.getNpmPackageAge(pkgName, actualVersion); - if (ageInfo && ageInfo.ageMs !== null && ageInfo.ageMs < minAgeMs) { - const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); - this.issues.push({ - type: 'fresh_package_version', - file: filePath, - line: 1, - column: 1, - code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, - severity: 'medium' - }); - } + const { actualVersion, isRange } = parseVersionRange(version); + if (!isRange && !filePath.endsWith('package-lock.json')) { + continue; + } + const ageInfo = await this.getNpmPackageAge(pkgName, actualVersion); + if (ageInfo && ageInfo.ageMs < minAgeMs) { + const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); + this.issues.push({ + type: 'fresh_package_version', + file: filePath, + line: 1, + column: 1, + code: `"${pkgName}": "${version}"`, + message: `Package '${pkgName}' v${actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + severity: 'medium' + }); } } } diff --git a/package-lock.json b/package-lock.json index ea8195e..745f0a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "karpeslop", - "version": "1.0.22", + "version": "1.0.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.22", + "version": "1.0.25", "license": "MIT", "dependencies": { "glob": "^11.0.0", "tsx": "^4.19.1" }, "bin": { - "karpeslop": "karpeslop-bin.js" + "karpeslop": "karpeslop-cli.js" }, "devDependencies": { "@babel/cli": "^7.28.3", From 7a3924c7dd1a8dc637a4e46e5e341bf333b10824 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 12:55:58 -0400 Subject: [PATCH 18/38] fix: address new CodeRabbit review comments - getNpmPackageAge: wrap fetch in AbortController with 5s timeout so registry requests can't stall the scan indefinitely - analyzePackageVersions: derive package name from pkgPath when pkgInfo.name is missing in package-lock entries - CHANGELOG: add 1.0.25 release header to match package.json --- CHANGELOG.md | 16 ++++++++++++++++ ai-slop-detector.ts | 14 +++++++++++--- karpeslop-bin.js | 16 +++++++++++++--- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca1834..c375a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes will be documented in this file. +## [1.0.25] - 2026-06-09 + +### Added +- **Positional path arguments**: `karpeslop [options] [path...]` accepts file or directory targets so scans can focus on a single file or subset. The `--strict` mode now reports which targeted file(s) had critical issues. +- **`--` separator**: Use `--` to mark the end of flags, so paths starting with `-` aren't misclassified as flags. +- **Manifest file discovery**: `findAllFiles()` now picks up `package.json`/`package-lock.json` at the project root, and `resolveTargetPaths()` accepts `.json` for the two manifest filenames β€” making the `fresh_package_version` rule reachable from both full and targeted scans. +- **`--quiet` includes manifests**: Even with `--quiet`, the two manifest files are still analyzed so package freshness warnings aren't lost. + +### Fixed +- **package-lock.json bin entry**: Aligned to `karpeslop-cli.js` (matching `package.json`). +- **Config validation**: `minPackageAgeDays` now rejects non-finite/negative values at load time instead of silently producing NaN. + +### Refactored +- Shared `getGlobIgnorePatterns()` and `isExcludedPath()` between `findAllFiles()` and `resolveTargetPaths()` so both paths use the same exclusion logic. +- `isExcludedPath` flattened with De Morgan's law; segment-based check for dotfiles and `types/`. + ## [1.0.24] - 2026-04-04 ### Fixed diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index a63a1e6..25e43ab 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1108,8 +1108,9 @@ class AISlopDetector { if (pkgPath === '' || pkgPath === 'node_modules/') continue; // Skip nested node_modules entries to avoid transitive double-counting if (pkgPath.split('node_modules/').length > 2) continue; - const name = (pkgInfo as Record).name as string; - const version = (pkgInfo as Record).version as string; + const info = pkgInfo as Record; + const name = (info.name as string) || pkgPath.split('node_modules/').pop()!; + const version = info.version as string; if (name && version) { packageData[name] = version; } @@ -1165,7 +1166,14 @@ class AISlopDetector { try { const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; - const response = await fetch(url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + let response: Response; + try { + response = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } if (!response.ok) return null; const data = (await response.json()) as { time: Record }; diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 007a1d4..0d327c3 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -925,8 +925,9 @@ class AISlopDetector { if (pkgPath === '' || pkgPath === 'node_modules/') continue; // Skip nested node_modules entries to avoid transitive double-counting if (pkgPath.split('node_modules/').length > 2) continue; - const name = pkgInfo.name; - const version = pkgInfo.version; + const info = pkgInfo; + const name = info.name || pkgPath.split('node_modules/').pop(); + const version = info.version; if (name && version) { packageData[name] = version; } @@ -986,7 +987,16 @@ class AISlopDetector { } try { const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; - const response = await fetch(url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + let response; + try { + response = await fetch(url, { + signal: controller.signal + }); + } finally { + clearTimeout(timer); + } if (!response.ok) return null; const data = await response.json(); const versionTime = data.time?.[version]; From a7bfa788512f00af232fcf0d8357221d7e5c1a91 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 13:19:49 -0400 Subject: [PATCH 19/38] fix: address new code review findings (perf, caching, error handling) - Parallelize getNpmPackageAge calls in analyzePackageVersions via Promise.all so registry requests run concurrently instead of sequentially (was N x 5s in worst case) - Cache the full time map from the registry response instead of just the looked-up version, so different versions of the same package hit cache rather than re-fetching megabytes of data - Log a one-time warning when the npm registry can't be reached instead of silently swallowing network errors - Defer detector construction past --help/--version so a bad .karpesloprc.json doesn't break help output - Drop dead info.name branch in lockfile parsing: name is derived from pkgPath since the only entry that has info.name is the root which is already skipped --- ai-slop-detector.ts | 76 +++++++++++++++++++++++++------------------ karpeslop-bin.js | 79 ++++++++++++++++++++++++++------------------- 2 files changed, 90 insertions(+), 65 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 25e43ab..5abaedc 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -319,7 +319,8 @@ class AISlopDetector { private config: KarpeSlopConfig = {}; private customIgnorePaths: string[] = []; - private npmPackageCache: Map = new Map(); + private npmPackageCache: Map> = new Map(); + private registryWarningLogged = false; private targetPaths: string[] = []; constructor(private rootDir: string, targetPaths?: string[]) { @@ -1108,9 +1109,10 @@ class AISlopDetector { if (pkgPath === '' || pkgPath === 'node_modules/') continue; // Skip nested node_modules entries to avoid transitive double-counting if (pkgPath.split('node_modules/').length > 2) continue; - const info = pkgInfo as Record; - const name = (info.name as string) || pkgPath.split('node_modules/').pop()!; - const version = info.version as string; + const version = (pkgInfo as Record).version as string; + // pkgPath looks like "node_modules/foo" or "node_modules/@scope/bar" + // The last segment is always the package name for non-root entries + const name = pkgPath.split('node_modules/').pop()!; if (name && version) { packageData[name] = version; } @@ -1128,12 +1130,19 @@ class AISlopDetector { return; } - for (const [pkgName, version] of Object.entries(packageData)) { - const { actualVersion, isRange } = parseVersionRange(version); - if (!isRange && !filePath.endsWith('package-lock.json')) { - continue; - } - const ageInfo = await this.getNpmPackageAge(pkgName, actualVersion); + const entries = Object.entries(packageData).filter(([, version]) => { + const { isRange } = parseVersionRange(version); + return isRange || filePath.endsWith('package-lock.json'); + }); + + const ageInfos = await Promise.all( + entries.map(([pkgName, version]) => + this.getNpmPackageAge(pkgName, parseVersionRange(version).actualVersion) + .then(ageInfo => ({ pkgName, version, ageInfo })) + ) + ); + + for (const { pkgName, version, ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); this.issues.push({ @@ -1142,7 +1151,7 @@ class AISlopDetector { line: 1, column: 1, code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + message: `Package '${pkgName}' v${parseVersionRange(version).actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, severity: 'medium' }); } @@ -1150,20 +1159,23 @@ class AISlopDetector { } /** - * Get npm package version age info with caching + * Get npm package version age info with caching. + * Cache stores the full `time` map from the registry so repeated lookups + * for different versions of the same package don't re-fetch the package. */ private async getNpmPackageAge(pkgName: string, version: string): Promise<{ version: string; time: string; ageMs: number } | null> { const now = Date.now(); const cached = this.npmPackageCache.get(pkgName); if (cached) { - const found = cached.find(v => v.version === version); - if (found) { - const publishTime = new Date(found.time).getTime(); - return { version, time: found.time, ageMs: now - publishTime }; + const versionTime = cached[version]; + if (versionTime) { + const publishTime = new Date(versionTime).getTime(); + return { version, time: versionTime, ageMs: now - publishTime }; } } + let data: { time: Record } | null = null; try { const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; const controller = new AbortController(); @@ -1175,22 +1187,22 @@ class AISlopDetector { clearTimeout(timer); } if (!response.ok) return null; - - const data = (await response.json()) as { time: Record }; - const versionTime = data.time?.[version]; - if (!versionTime) return null; - - const publishTime = new Date(versionTime).getTime(); - - if (!this.npmPackageCache.has(pkgName)) { - this.npmPackageCache.set(pkgName, []); - } - this.npmPackageCache.get(pkgName)!.push({ time: versionTime, version }); - - return { version, time: versionTime, ageMs: now - publishTime }; + data = (await response.json()) as { time: Record }; } catch { + if (!this.registryWarningLogged) { + console.warn('⚠️ Could not reach npm registry to check package ages. fresh_package_version checks will be skipped.'); + this.registryWarningLogged = true; + } return null; } + + if (data?.time) { + this.npmPackageCache.set(pkgName, data.time); + } + const versionTime = data?.time?.[version]; + if (!versionTime) return null; + const publishTime = new Date(versionTime).getTime(); + return { version, time: versionTime, ageMs: now - publishTime }; } /** @@ -1549,9 +1561,8 @@ async function runIfMain() { targetPaths = args.filter(a => !a.startsWith('-')); } - const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); - - // Check for help options first + // Check for help options first, before constructing the detector + // (so a bad config doesn't break --help) if (flagArgs.includes('--help') || flagArgs.includes('-h') || flagArgs.includes('/?')) { console.log(` Usage: karpeslop [options] [path...] @@ -1602,6 +1613,7 @@ The tool detects the three axes of AI slop: process.exit(0); } + const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); const quiet = flagArgs.includes('--quiet') || flagArgs.includes('-q'); const strict = flagArgs.includes('--strict') || flagArgs.includes('-s') || !!detector.getConfig().blockOnCritical; diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 0d327c3..b49a9cb 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -242,6 +242,7 @@ class AISlopDetector { config = {}; customIgnorePaths = []; npmPackageCache = new Map(); + registryWarningLogged = false; targetPaths = []; constructor(rootDir, targetPaths) { this.rootDir = rootDir; @@ -925,9 +926,10 @@ class AISlopDetector { if (pkgPath === '' || pkgPath === 'node_modules/') continue; // Skip nested node_modules entries to avoid transitive double-counting if (pkgPath.split('node_modules/').length > 2) continue; - const info = pkgInfo; - const name = info.name || pkgPath.split('node_modules/').pop(); - const version = info.version; + const version = pkgInfo.version; + // pkgPath looks like "node_modules/foo" or "node_modules/@scope/bar" + // The last segment is always the package name for non-root entries + const name = pkgPath.split('node_modules/').pop(); if (name && version) { packageData[name] = version; } @@ -944,15 +946,22 @@ class AISlopDetector { } catch { return; } - for (const [pkgName, version] of Object.entries(packageData)) { + const entries = Object.entries(packageData).filter(([, version]) => { const { - actualVersion, isRange } = parseVersionRange(version); - if (!isRange && !filePath.endsWith('package-lock.json')) { - continue; - } - const ageInfo = await this.getNpmPackageAge(pkgName, actualVersion); + return isRange || filePath.endsWith('package-lock.json'); + }); + const ageInfos = await Promise.all(entries.map(([pkgName, version]) => this.getNpmPackageAge(pkgName, parseVersionRange(version).actualVersion).then(ageInfo => ({ + pkgName, + version, + ageInfo + })))); + for (const { + pkgName, + version, + ageInfo + } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); this.issues.push({ @@ -961,7 +970,7 @@ class AISlopDetector { line: 1, column: 1, code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + message: `Package '${pkgName}' v${parseVersionRange(version).actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, severity: 'medium' }); } @@ -969,22 +978,25 @@ class AISlopDetector { } /** - * Get npm package version age info with caching + * Get npm package version age info with caching. + * Cache stores the full `time` map from the registry so repeated lookups + * for different versions of the same package don't re-fetch the package. */ async getNpmPackageAge(pkgName, version) { const now = Date.now(); const cached = this.npmPackageCache.get(pkgName); if (cached) { - const found = cached.find(v => v.version === version); - if (found) { - const publishTime = new Date(found.time).getTime(); + const versionTime = cached[version]; + if (versionTime) { + const publishTime = new Date(versionTime).getTime(); return { version, - time: found.time, + time: versionTime, ageMs: now - publishTime }; } } + let data = null; try { const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; const controller = new AbortController(); @@ -998,25 +1010,25 @@ class AISlopDetector { clearTimeout(timer); } if (!response.ok) return null; - const data = await response.json(); - const versionTime = data.time?.[version]; - if (!versionTime) return null; - const publishTime = new Date(versionTime).getTime(); - if (!this.npmPackageCache.has(pkgName)) { - this.npmPackageCache.set(pkgName, []); - } - this.npmPackageCache.get(pkgName).push({ - time: versionTime, - version - }); - return { - version, - time: versionTime, - ageMs: now - publishTime - }; + data = await response.json(); } catch { + if (!this.registryWarningLogged) { + console.warn('⚠️ Could not reach npm registry to check package ages. fresh_package_version checks will be skipped.'); + this.registryWarningLogged = true; + } return null; } + if (data?.time) { + this.npmPackageCache.set(pkgName, data.time); + } + const versionTime = data?.time?.[version]; + if (!versionTime) return null; + const publishTime = new Date(versionTime).getTime(); + return { + version, + time: versionTime, + ageMs: now - publishTime + }; } /** @@ -1343,9 +1355,9 @@ async function runIfMain() { flagArgs = args.filter(a => a.startsWith('-')); targetPaths = args.filter(a => !a.startsWith('-')); } - const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); - // Check for help options first + // Check for help options first, before constructing the detector + // (so a bad config doesn't break --help) if (flagArgs.includes('--help') || flagArgs.includes('-h') || flagArgs.includes('/?')) { console.log(` Usage: karpeslop [options] [path...] @@ -1395,6 +1407,7 @@ The tool detects the three axes of AI slop: } process.exit(0); } + const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); const quiet = flagArgs.includes('--quiet') || flagArgs.includes('-q'); const strict = flagArgs.includes('--strict') || flagArgs.includes('-s') || !!detector.getConfig().blockOnCritical; try { From 0b156cf5a5e8397ead5d5bbe6eb01ed9a50b7857 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 13:36:47 -0400 Subject: [PATCH 20/38] fix: tighten package age scanning --- ai-slop-detector.ts | 104 +++++++++++++++++++++++++++++++------------- karpeslop-bin.js | 95 +++++++++++++++++++++++++++++++--------- 2 files changed, 148 insertions(+), 51 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 5abaedc..41bef60 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -72,8 +72,12 @@ interface KarpeSlopConfig { } /** - * Parse a semver range string (e.g. "^1.2.3", "~1.2.3", "1.2.3") into the - * concrete version and whether the original was a range. + * Parse a semver string into the concrete version and whether the original + * was a floating caret/tilde range. + * + * We intentionally only treat `^` and `~` as eligible for the package-age + * check because those are the common semver ranges that drift on install. + * Broader operators like `>=`, `1.x`, or `latest` are not resolved here. */ function parseVersionRange(version: string): { actualVersion: string; isRange: boolean } { if (version.startsWith('^') || version.startsWith('~')) { @@ -82,6 +86,25 @@ function parseVersionRange(version: string): { actualVersion: string; isRange: b return { actualVersion: version, isRange: false }; } +/** + * Run an array of async tasks with bounded concurrency. Used to throttle + * outbound HTTP calls to the npm registry (which rate-limits unauthenticated + * callers at ~600 req/min). + */ +async function pLimit(tasks: (() => Promise)[], concurrency: number): Promise { + const results: T[] = new Array(tasks.length); + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => { + while (true) { + const i = next++; + if (i >= tasks.length) return; + results[i] = await tasks[i](); + } + }); + await Promise.all(workers); + return results; +} + class AISlopDetector { private issues: AISlopIssue[] = []; private targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -529,19 +552,22 @@ class AISlopDetector { private isExcludedPath(filePath: string): boolean { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - return relativePath.includes('generated/') || - relativePath.includes('/generated') || - relativePath.startsWith('generated/') || - relativePath.includes('coverage/') || - relativePath.includes('.next/') || - relativePath.includes('node_modules/') || - relativePath.includes('dist/') || - relativePath.includes('build/') || - relativePath.includes('.git/') || - relativePath.includes('out/') || - relativePath.includes('temp/') || - relativePath.split('/').some(segment => segment.startsWith('.')) || - relativePath.split('/').includes('types') || + // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as + // a build artifact. A real `dist/` directory under `src/` is rare and + // matching it the way users expect is the lesser evil here. + const segments = relativePath.split('/'); + const excludedSegment = (name: string) => segments.includes(name); + return excludedSegment('generated') || + excludedSegment('coverage') || + excludedSegment('.next') || + excludedSegment('node_modules') || + excludedSegment('dist') || + excludedSegment('build') || + excludedSegment('.git') || + excludedSegment('out') || + excludedSegment('temp') || + excludedSegment('types') || + segments.some(segment => segment.startsWith('.')) || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); @@ -605,6 +631,15 @@ class AISlopDetector { const filteredFiles = files.filter(file => !this.isExcludedPath(file)); resolved.push(...filteredFiles); } + // Also pick up manifest files so fresh_package_version rule still fires + // when user targets a directory (e.g. `karpeslop src/lib/` with a + // package.json inside that directory) + for (const manifestName of this.manifestFilenames) { + const manifestPath = path.join(targetPath, manifestName); + if (fs.existsSync(manifestPath) && !this.isExcludedPath(manifestPath)) { + resolved.push(manifestPath); + } + } } } @@ -1130,16 +1165,22 @@ class AISlopDetector { return; } - const entries = Object.entries(packageData).filter(([, version]) => { - const { isRange } = parseVersionRange(version); - return isRange || filePath.endsWith('package-lock.json'); - }); - - const ageInfos = await Promise.all( - entries.map(([pkgName, version]) => - this.getNpmPackageAge(pkgName, parseVersionRange(version).actualVersion) - .then(ageInfo => ({ pkgName, version, ageInfo })) - ) + const entries = Object.entries(packageData) + .map(([pkgName, version]) => ({ pkgName, ...parseVersionRange(version) })) + // Keep the rule narrow on purpose: only caret/tilde ranges are treated + // as "fresh package" candidates in package.json. package-lock.json is + // always checked because it contains the resolved version. + .filter(({ isRange }) => isRange || filePath.endsWith('package-lock.json')); + + // Bound concurrency: npm registry rate-limits unauthenticated callers at + // ~600 req/min, and a typical package-lock.json has 200-1000 deps. + const NPM_CONCURRENCY = 5; + const ageInfos = await pLimit( + entries.map(({ pkgName, actualVersion }) => () => + this.getNpmPackageAge(pkgName, actualVersion) + .then(ageInfo => ({ pkgName, version: actualVersion, ageInfo })) + ), + NPM_CONCURRENCY ); for (const { pkgName, version, ageInfo } of ageInfos) { @@ -1151,7 +1192,7 @@ class AISlopDetector { line: 1, column: 1, code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${parseVersionRange(version).actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + message: `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, severity: 'medium' }); } @@ -1168,11 +1209,14 @@ class AISlopDetector { const cached = this.npmPackageCache.get(pkgName); if (cached) { + // Cached map contains every version the registry knows about. If our + // requested version isn't here, the version genuinely doesn't exist + // (or was unpublished) β€” don't refetch, that would just re-fetch the + // same map we already have. const versionTime = cached[version]; - if (versionTime) { - const publishTime = new Date(versionTime).getTime(); - return { version, time: versionTime, ageMs: now - publishTime }; - } + if (!versionTime) return null; + const publishTime = new Date(versionTime).getTime(); + return { version, time: versionTime, ageMs: now - publishTime }; } let data: { time: Record } | null = null; diff --git a/karpeslop-bin.js b/karpeslop-bin.js index b49a9cb..3435d30 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -16,8 +16,12 @@ import { fileURLToPath } from 'url'; // Phase 6: Configuration file support /** - * Parse a semver range string (e.g. "^1.2.3", "~1.2.3", "1.2.3") into the - * concrete version and whether the original was a range. + * Parse a semver string into the concrete version and whether the original + * was a floating caret/tilde range. + * + * We intentionally only treat `^` and `~` as eligible for the package-age + * check because those are the common semver ranges that drift on install. + * Broader operators like `>=`, `1.x`, or `latest` are not resolved here. */ function parseVersionRange(version) { if (version.startsWith('^') || version.startsWith('~')) { @@ -31,6 +35,27 @@ function parseVersionRange(version) { isRange: false }; } + +/** + * Run an array of async tasks with bounded concurrency. Used to throttle + * outbound HTTP calls to the npm registry (which rate-limits unauthenticated + * callers at ~600 req/min). + */ +async function pLimit(tasks, concurrency) { + const results = new Array(tasks.length); + let next = 0; + const workers = Array.from({ + length: Math.min(concurrency, tasks.length) + }, async () => { + while (true) { + const i = next++; + if (i >= tasks.length) return; + results[i] = await tasks[i](); + } + }); + await Promise.all(workers); + return results; +} class AISlopDetector { issues = []; targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -412,7 +437,12 @@ class AISlopDetector { } isExcludedPath(filePath) { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - return relativePath.includes('generated/') || relativePath.includes('/generated') || relativePath.startsWith('generated/') || relativePath.includes('coverage/') || relativePath.includes('.next/') || relativePath.includes('node_modules/') || relativePath.includes('dist/') || relativePath.includes('build/') || relativePath.includes('.git/') || relativePath.includes('out/') || relativePath.includes('temp/') || relativePath.split('/').some(segment => segment.startsWith('.')) || relativePath.split('/').includes('types') || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); + // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as + // a build artifact. A real `dist/` directory under `src/` is rare and + // matching it the way users expect is the lesser evil here. + const segments = relativePath.split('/'); + const excludedSegment = name => segments.includes(name); + return excludedSegment('generated') || excludedSegment('coverage') || excludedSegment('.next') || excludedSegment('node_modules') || excludedSegment('dist') || excludedSegment('build') || excludedSegment('.git') || excludedSegment('out') || excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); } /** @@ -473,6 +503,15 @@ class AISlopDetector { const filteredFiles = files.filter(file => !this.isExcludedPath(file)); resolved.push(...filteredFiles); } + // Also pick up manifest files so fresh_package_version rule still fires + // when user targets a directory (e.g. `karpeslop src/lib/` with a + // package.json inside that directory) + for (const manifestName of this.manifestFilenames) { + const manifestPath = path.join(targetPath, manifestName); + if (fs.existsSync(manifestPath) && !this.isExcludedPath(manifestPath)) { + resolved.push(manifestPath); + } + } } } return [...new Set(resolved)]; @@ -946,17 +985,28 @@ class AISlopDetector { } catch { return; } - const entries = Object.entries(packageData).filter(([, version]) => { - const { - isRange - } = parseVersionRange(version); - return isRange || filePath.endsWith('package-lock.json'); - }); - const ageInfos = await Promise.all(entries.map(([pkgName, version]) => this.getNpmPackageAge(pkgName, parseVersionRange(version).actualVersion).then(ageInfo => ({ + const entries = Object.entries(packageData).map(([pkgName, version]) => ({ pkgName, - version, + ...parseVersionRange(version) + })) + // Keep the rule narrow on purpose: only caret/tilde ranges are treated + // as "fresh package" candidates in package.json. package-lock.json is + // always checked because it contains the resolved version. + .filter(({ + isRange + }) => isRange || filePath.endsWith('package-lock.json')); + + // Bound concurrency: npm registry rate-limits unauthenticated callers at + // ~600 req/min, and a typical package-lock.json has 200-1000 deps. + const NPM_CONCURRENCY = 5; + const ageInfos = await pLimit(entries.map(({ + pkgName, + actualVersion + }) => () => this.getNpmPackageAge(pkgName, actualVersion).then(ageInfo => ({ + pkgName, + version: actualVersion, ageInfo - })))); + }))), NPM_CONCURRENCY); for (const { pkgName, version, @@ -970,7 +1020,7 @@ class AISlopDetector { line: 1, column: 1, code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${parseVersionRange(version).actualVersion} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + message: `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, severity: 'medium' }); } @@ -986,15 +1036,18 @@ class AISlopDetector { const now = Date.now(); const cached = this.npmPackageCache.get(pkgName); if (cached) { + // Cached map contains every version the registry knows about. If our + // requested version isn't here, the version genuinely doesn't exist + // (or was unpublished) β€” don't refetch, that would just re-fetch the + // same map we already have. const versionTime = cached[version]; - if (versionTime) { - const publishTime = new Date(versionTime).getTime(); - return { - version, - time: versionTime, - ageMs: now - publishTime - }; - } + if (!versionTime) return null; + const publishTime = new Date(versionTime).getTime(); + return { + version, + time: versionTime, + ageMs: now - publishTime + }; } let data = null; try { From 4937743f6dfaa2752025b1e645f300804cd827aa Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 14:01:11 -0400 Subject: [PATCH 21/38] fix: include workspace manifests in scans --- ai-slop-detector.ts | 36 ++++++++++++++++++++++++------------ karpeslop-bin.js | 34 ++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 41bef60..3203d7e 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -344,6 +344,7 @@ class AISlopDetector { private customIgnorePaths: string[] = []; private npmPackageCache: Map> = new Map(); private registryWarningLogged = false; + private reportedFreshPackageKeys = new Set(); private targetPaths: string[] = []; constructor(private rootDir: string, targetPaths?: string[]) { @@ -586,12 +587,16 @@ class AISlopDetector { allFiles.push(...filteredFiles); } - // Also pick up manifest files at the project root for package-age analysis + // Also pick up manifest files at the project root and below it for + // package-age analysis in monorepos/workspaces. for (const name of this.manifestFilenames) { - const manifestPath = path.join(this.rootDir, name); - if (fs.existsSync(manifestPath) && !this.isExcludedPath(manifestPath)) { - allFiles.push(manifestPath); - } + const rootManifestPath = path.join(this.rootDir, name); + const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); + const manifestFiles = [ + ...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), + ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) + ].filter(file => !this.isExcludedPath(file)); + allFiles.push(...manifestFiles); } // Remove duplicates and return @@ -631,14 +636,16 @@ class AISlopDetector { const filteredFiles = files.filter(file => !this.isExcludedPath(file)); resolved.push(...filteredFiles); } - // Also pick up manifest files so fresh_package_version rule still fires - // when user targets a directory (e.g. `karpeslop src/lib/` with a - // package.json inside that directory) for (const manifestName of this.manifestFilenames) { - const manifestPath = path.join(targetPath, manifestName); - if (fs.existsSync(manifestPath) && !this.isExcludedPath(manifestPath)) { - resolved.push(manifestPath); - } + // Pick up manifests both at the directory root and below it so the + // fresh_package_version rule still fires in monorepos/workspaces. + const rootManifestPath = path.join(targetPath, manifestName); + const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); + const manifestFiles = [ + ...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), + ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) + ].filter(file => !this.isExcludedPath(file)); + resolved.push(...manifestFiles); } } } @@ -1185,6 +1192,11 @@ class AISlopDetector { for (const { pkgName, version, ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { + const issueKey = `${pkgName}|${version}`; + if (this.reportedFreshPackageKeys.has(issueKey)) { + continue; + } + this.reportedFreshPackageKeys.add(issueKey); const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); this.issues.push({ type: 'fresh_package_version', diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 3435d30..ed67d41 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -268,6 +268,7 @@ class AISlopDetector { customIgnorePaths = []; npmPackageCache = new Map(); registryWarningLogged = false; + reportedFreshPackageKeys = new Set(); targetPaths = []; constructor(rootDir, targetPaths) { this.rootDir = rootDir; @@ -459,12 +460,15 @@ class AISlopDetector { allFiles.push(...filteredFiles); } - // Also pick up manifest files at the project root for package-age analysis + // Also pick up manifest files at the project root and below it for + // package-age analysis in monorepos/workspaces. for (const name of this.manifestFilenames) { - const manifestPath = path.join(this.rootDir, name); - if (fs.existsSync(manifestPath) && !this.isExcludedPath(manifestPath)) { - allFiles.push(manifestPath); - } + const rootManifestPath = path.join(this.rootDir, name); + const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); + const manifestFiles = [...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + ignore: this.getGlobIgnorePatterns() + })].filter(file => !this.isExcludedPath(file)); + allFiles.push(...manifestFiles); } // Remove duplicates and return @@ -503,14 +507,15 @@ class AISlopDetector { const filteredFiles = files.filter(file => !this.isExcludedPath(file)); resolved.push(...filteredFiles); } - // Also pick up manifest files so fresh_package_version rule still fires - // when user targets a directory (e.g. `karpeslop src/lib/` with a - // package.json inside that directory) for (const manifestName of this.manifestFilenames) { - const manifestPath = path.join(targetPath, manifestName); - if (fs.existsSync(manifestPath) && !this.isExcludedPath(manifestPath)) { - resolved.push(manifestPath); - } + // Pick up manifests both at the directory root and below it so the + // fresh_package_version rule still fires in monorepos/workspaces. + const rootManifestPath = path.join(targetPath, manifestName); + const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); + const manifestFiles = [...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + ignore: this.getGlobIgnorePatterns() + })].filter(file => !this.isExcludedPath(file)); + resolved.push(...manifestFiles); } } } @@ -1013,6 +1018,11 @@ class AISlopDetector { ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { + const issueKey = `${pkgName}|${version}`; + if (this.reportedFreshPackageKeys.has(issueKey)) { + continue; + } + this.reportedFreshPackageKeys.add(issueKey); const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); this.issues.push({ type: 'fresh_package_version', From a86c71998ec796cdc72e60cfdfa73b9f35298359 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 14:10:15 -0400 Subject: [PATCH 22/38] fix: scope manifest dedupe and wrapper exits --- ai-slop-detector.ts | 2 +- karpeslop-bin.js | 2 +- karpeslop-cli.js | 24 +++++++++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 3203d7e..2e7d876 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1192,7 +1192,7 @@ class AISlopDetector { for (const { pkgName, version, ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${pkgName}|${version}`; + const issueKey = `${filePath}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index ed67d41..80cbcd1 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -1018,7 +1018,7 @@ class AISlopDetector { ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${pkgName}|${version}`; + const issueKey = `${filePath}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } diff --git a/karpeslop-cli.js b/karpeslop-cli.js index af07d73..4a39d1e 100644 --- a/karpeslop-cli.js +++ b/karpeslop-cli.js @@ -17,6 +17,24 @@ const tsxPathWin = join(__dirname, 'node_modules', '.bin', 'tsx.cmd'); const isWindows = process.platform === 'win32'; const tsxCommand = isWindows ? tsxPathWin : tsxPath; +function exitCodeForSignal(signal) { + const signalExitCodes = { + SIGINT: 130, + SIGTERM: 143, + SIGHUP: 129, + SIGQUIT: 131 + }; + + return signalExitCodes[signal] || 1; +} + +function handleChildExit(code, signal) { + if (signal) { + process.exit(exitCodeForSignal(signal)); + } + process.exit(code ?? 0); +} + // Get command line arguments, excluding the first two (node and script path) const args = process.argv.slice(2); @@ -42,11 +60,11 @@ child.on('error', (err) => { console.error('Fallback execution also failed:', nodeErr.message); process.exit(1); }); + + nodeChild.on('exit', handleChildExit); } else { process.exit(1); } }); -child.on('exit', (code) => { - process.exit(code || 0); -}); \ No newline at end of file +child.on('exit', handleChildExit); From 04f3ea160508dd5c93577ac127ca41881f5c1f79 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 14:18:12 -0400 Subject: [PATCH 23/38] fix: preserve lockfile entry identity --- ai-slop-detector.ts | 39 +++++++++++++++++++++++---------------- karpeslop-bin.js | 41 +++++++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 2e7d876..114f54d 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1140,7 +1140,7 @@ class AISlopDetector { const minAgeDays = this.config.minPackageAgeDays ?? 7; const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; - let packageData: Record = {}; + const packageEntries: Array<{ sourceId: string; pkgName: string; version: string }> = []; try { const pkg = JSON.parse(content); @@ -1151,12 +1151,13 @@ class AISlopDetector { if (pkgPath === '' || pkgPath === 'node_modules/') continue; // Skip nested node_modules entries to avoid transitive double-counting if (pkgPath.split('node_modules/').length > 2) continue; - const version = (pkgInfo as Record).version as string; - // pkgPath looks like "node_modules/foo" or "node_modules/@scope/bar" - // The last segment is always the package name for non-root entries - const name = pkgPath.split('node_modules/').pop()!; - if (name && version) { - packageData[name] = version; + const info = pkgInfo as Record; + const version = info.version as string; + const pkgName = typeof info.name === 'string' && info.name + ? info.name + : pkgPath.split('node_modules/').pop() || pkgPath; + if (pkgName && version) { + packageEntries.push({ sourceId: pkgPath, pkgName, version }); } } } @@ -1164,7 +1165,9 @@ class AISlopDetector { const p = pkg as Record | undefined>; for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { if (p[key]) { - Object.assign(packageData, p[key]); + for (const [pkgName, version] of Object.entries(p[key]!)) { + packageEntries.push({ sourceId: key, pkgName, version }); + } } } } @@ -1172,8 +1175,8 @@ class AISlopDetector { return; } - const entries = Object.entries(packageData) - .map(([pkgName, version]) => ({ pkgName, ...parseVersionRange(version) })) + const entries = packageEntries + .map(({ sourceId, pkgName, version }) => ({ sourceId, pkgName, ...parseVersionRange(version) })) // Keep the rule narrow on purpose: only caret/tilde ranges are treated // as "fresh package" candidates in package.json. package-lock.json is // always checked because it contains the resolved version. @@ -1183,16 +1186,16 @@ class AISlopDetector { // ~600 req/min, and a typical package-lock.json has 200-1000 deps. const NPM_CONCURRENCY = 5; const ageInfos = await pLimit( - entries.map(({ pkgName, actualVersion }) => () => + entries.map(({ sourceId, pkgName, actualVersion }) => () => this.getNpmPackageAge(pkgName, actualVersion) - .then(ageInfo => ({ pkgName, version: actualVersion, ageInfo })) + .then(ageInfo => ({ sourceId, pkgName, version: actualVersion, ageInfo })) ), NPM_CONCURRENCY ); - for (const { pkgName, version, ageInfo } of ageInfos) { + for (const { sourceId, pkgName, version, ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${filePath}|${pkgName}|${version}`; + const issueKey = `${filePath}|${sourceId}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } @@ -1203,8 +1206,12 @@ class AISlopDetector { file: filePath, line: 1, column: 1, - code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + code: filePath.endsWith('package-lock.json') + ? `${sourceId}: ${pkgName}@${version}` + : `"${pkgName}": "${version}"`, + message: filePath.endsWith('package-lock.json') + ? `Package '${pkgName}' from ${sourceId} v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating` + : `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, severity: 'medium' }); } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 80cbcd1..3b0df82 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -961,7 +961,7 @@ class AISlopDetector { async analyzePackageVersions(filePath, content) { const minAgeDays = this.config.minPackageAgeDays ?? 7; const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; - let packageData = {}; + const packageEntries = []; try { const pkg = JSON.parse(content); if (filePath.endsWith('package-lock.json')) { @@ -970,12 +970,15 @@ class AISlopDetector { if (pkgPath === '' || pkgPath === 'node_modules/') continue; // Skip nested node_modules entries to avoid transitive double-counting if (pkgPath.split('node_modules/').length > 2) continue; - const version = pkgInfo.version; - // pkgPath looks like "node_modules/foo" or "node_modules/@scope/bar" - // The last segment is always the package name for non-root entries - const name = pkgPath.split('node_modules/').pop(); - if (name && version) { - packageData[name] = version; + const info = pkgInfo; + const version = info.version; + const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; + if (pkgName && version) { + packageEntries.push({ + sourceId: pkgPath, + pkgName, + version + }); } } } @@ -983,14 +986,25 @@ class AISlopDetector { const p = pkg; for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { if (p[key]) { - Object.assign(packageData, p[key]); + for (const [pkgName, version] of Object.entries(p[key])) { + packageEntries.push({ + sourceId: key, + pkgName, + version + }); + } } } } } catch { return; } - const entries = Object.entries(packageData).map(([pkgName, version]) => ({ + const entries = packageEntries.map(({ + sourceId, + pkgName, + version + }) => ({ + sourceId, pkgName, ...parseVersionRange(version) })) @@ -1005,20 +1019,23 @@ class AISlopDetector { // ~600 req/min, and a typical package-lock.json has 200-1000 deps. const NPM_CONCURRENCY = 5; const ageInfos = await pLimit(entries.map(({ + sourceId, pkgName, actualVersion }) => () => this.getNpmPackageAge(pkgName, actualVersion).then(ageInfo => ({ + sourceId, pkgName, version: actualVersion, ageInfo }))), NPM_CONCURRENCY); for (const { + sourceId, pkgName, version, ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${filePath}|${pkgName}|${version}`; + const issueKey = `${filePath}|${sourceId}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } @@ -1029,8 +1046,8 @@ class AISlopDetector { file: filePath, line: 1, column: 1, - code: `"${pkgName}": "${version}"`, - message: `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, + code: filePath.endsWith('package-lock.json') ? `${sourceId}: ${pkgName}@${version}` : `"${pkgName}": "${version}"`, + message: filePath.endsWith('package-lock.json') ? `Package '${pkgName}' from ${sourceId} v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating` : `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, severity: 'medium' }); } From 74a5e1423e11877ca2fb86686d4aec5fdedf2586 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 15:19:10 -0400 Subject: [PATCH 24/38] fix: honor ignore paths for explicit targets --- ai-slop-detector.ts | 30 ++++++++++++++++++++++++++---- karpeslop-bin.js | 25 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 114f54d..d5b6114 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -551,8 +551,28 @@ class AISlopDetector { ]; } - private isExcludedPath(filePath: string): boolean { + private isIgnoredByConfig(filePath: string): boolean { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + if (relativePath.startsWith('..')) { + return false; + } + + return glob.sync(relativePath, { + cwd: this.rootDir, + ignore: this.getGlobIgnorePatterns() + }).length === 0; + } + + private isExcludedPath(filePath: string, allowOutsideRoot: boolean = false): boolean { + const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + const isOutsideRoot = relativePath.startsWith('..'); + + if (allowOutsideRoot && isOutsideRoot) { + return relativePath.endsWith('.d.ts') || + relativePath.endsWith('ai-slop-detector.ts') || + relativePath.endsWith('improved-ai-slop-detector.ts'); + } + // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as // a build artifact. A real `dist/` directory under `src/` is rare and // matching it the way users expect is the lesser evil here. @@ -622,7 +642,9 @@ class AISlopDetector { const ext = path.extname(targetPath); const base = path.basename(targetPath); const isManifest = ext === '.json' && this.manifestFilenames.includes(base); - if (this.isExcludedPath(targetPath)) { + if (this.isIgnoredByConfig(targetPath)) { + console.warn(`⚠️ Target file matches ignore paths, skipping: ${targetPath}`); + } else if (this.isExcludedPath(targetPath, true)) { console.warn(`⚠️ Target file is in an excluded path, skipping: ${targetPath}`); } else if (this.targetExtensions.includes(ext) || isManifest) { resolved.push(targetPath); @@ -633,7 +655,7 @@ class AISlopDetector { for (const ext of this.targetExtensions) { const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); - const filteredFiles = files.filter(file => !this.isExcludedPath(file)); + const filteredFiles = files.filter(file => !this.isExcludedPath(file, true)); resolved.push(...filteredFiles); } for (const manifestName of this.manifestFilenames) { @@ -644,7 +666,7 @@ class AISlopDetector { const manifestFiles = [ ...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) - ].filter(file => !this.isExcludedPath(file)); + ].filter(file => !this.isExcludedPath(file, true)); resolved.push(...manifestFiles); } } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 3b0df82..8112e1f 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -436,8 +436,23 @@ class AISlopDetector { getGlobIgnorePatterns() { return ['node_modules/**', '.next/**', 'dist/**', 'build/**', 'coverage/**', 'generated/**', '.vercel/**', '.git/**', '**/types/**', '**/node_modules/**', '**/.*', '**/*.d.ts', '**/coverage/**', '**/out/**', '**/temp/**', 'scripts/ai-slop-detector.ts', 'ai-slop-detector.ts', 'improved-ai-slop-detector.ts', ...this.customIgnorePaths]; } - isExcludedPath(filePath) { + isIgnoredByConfig(filePath) { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + if (relativePath.startsWith('..')) { + return false; + } + return glob.sync(relativePath, { + cwd: this.rootDir, + ignore: this.getGlobIgnorePatterns() + }).length === 0; + } + isExcludedPath(filePath, allowOutsideRoot = false) { + const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + const isOutsideRoot = relativePath.startsWith('..'); + if (allowOutsideRoot && isOutsideRoot) { + return relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); + } + // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as // a build artifact. A real `dist/` directory under `src/` is rare and // matching it the way users expect is the lesser evil here. @@ -491,7 +506,9 @@ class AISlopDetector { const ext = path.extname(targetPath); const base = path.basename(targetPath); const isManifest = ext === '.json' && this.manifestFilenames.includes(base); - if (this.isExcludedPath(targetPath)) { + if (this.isIgnoredByConfig(targetPath)) { + console.warn(`⚠️ Target file matches ignore paths, skipping: ${targetPath}`); + } else if (this.isExcludedPath(targetPath, true)) { console.warn(`⚠️ Target file is in an excluded path, skipping: ${targetPath}`); } else if (this.targetExtensions.includes(ext) || isManifest) { resolved.push(targetPath); @@ -504,7 +521,7 @@ class AISlopDetector { const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); - const filteredFiles = files.filter(file => !this.isExcludedPath(file)); + const filteredFiles = files.filter(file => !this.isExcludedPath(file, true)); resolved.push(...filteredFiles); } for (const manifestName of this.manifestFilenames) { @@ -514,7 +531,7 @@ class AISlopDetector { const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); const manifestFiles = [...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() - })].filter(file => !this.isExcludedPath(file)); + })].filter(file => !this.isExcludedPath(file, true)); resolved.push(...manifestFiles); } } From 8f6af27e77b02d8db8ed8f3a1c7f2eb85c3ddc47 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 15:55:14 -0400 Subject: [PATCH 25/38] fix: honor ignore globs and empty scans --- ai-slop-detector.ts | 11 +++++++++-- karpeslop-bin.js | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index d5b6114..b7b3e57 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -501,6 +501,9 @@ class AISlopDetector { if (this.targetPaths.length > 0) { filesToAnalyze = this.resolveTargetPaths(); console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); + if (filesToAnalyze.length === 0) { + throw new Error('No valid target files found for the supplied paths'); + } } else { const allFiles = this.findAllFiles(); @@ -613,7 +616,9 @@ class AISlopDetector { const rootManifestPath = path.join(this.rootDir, name); const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); const manifestFiles = [ - ...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), + ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath) + ? [rootManifestPath] + : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) ].filter(file => !this.isExcludedPath(file)); allFiles.push(...manifestFiles); @@ -664,7 +669,9 @@ class AISlopDetector { const rootManifestPath = path.join(targetPath, manifestName); const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); const manifestFiles = [ - ...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), + ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath, true) + ? [rootManifestPath] + : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) ].filter(file => !this.isExcludedPath(file, true)); resolved.push(...manifestFiles); diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 8112e1f..74ea5d0 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -413,6 +413,9 @@ class AISlopDetector { if (this.targetPaths.length > 0) { filesToAnalyze = this.resolveTargetPaths(); console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); + if (filesToAnalyze.length === 0) { + throw new Error('No valid target files found for the supplied paths'); + } } else { const allFiles = this.findAllFiles(); filesToAnalyze = quiet ? allFiles.filter(file => { @@ -480,7 +483,7 @@ class AISlopDetector { for (const name of this.manifestFilenames) { const rootManifestPath = path.join(this.rootDir, name); const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() })].filter(file => !this.isExcludedPath(file)); allFiles.push(...manifestFiles); @@ -529,7 +532,7 @@ class AISlopDetector { // fresh_package_version rule still fires in monorepos/workspaces. const rootManifestPath = path.join(targetPath, manifestName); const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath, true) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() })].filter(file => !this.isExcludedPath(file, true)); resolved.push(...manifestFiles); From b1199cd1c690acf96643361673ad1b67dec673f5 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 16:33:51 -0400 Subject: [PATCH 26/38] fix: support legacy lockfiles in freshness scan --- ai-slop-detector.ts | 30 ++++++++++++++++++++++++++++++ karpeslop-bin.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index b7b3e57..b4ddbd4 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1170,6 +1170,30 @@ class AISlopDetector { const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; const packageEntries: Array<{ sourceId: string; pkgName: string; version: string }> = []; + const collectLegacyLockfileEntries = (dependencies: unknown, trail: string[] = []): void => { + if (typeof dependencies !== 'object' || dependencies === null) { + return; + } + + for (const [depName, depInfo] of Object.entries(dependencies as Record)) { + if (typeof depInfo !== 'object' || depInfo === null) { + continue; + } + + const info = depInfo as Record; + const version = typeof info.version === 'string' ? info.version : ''; + const pkgName = typeof info.name === 'string' && info.name ? info.name : depName; + const sourceId = ['dependencies', ...trail, depName].join('/'); + + if (pkgName && version) { + packageEntries.push({ sourceId, pkgName, version }); + } + + if (info.dependencies && typeof info.dependencies === 'object') { + collectLegacyLockfileEntries(info.dependencies, [...trail, depName]); + } + } + }; try { const pkg = JSON.parse(content); @@ -1189,6 +1213,8 @@ class AISlopDetector { packageEntries.push({ sourceId: pkgPath, pkgName, version }); } } + } else if (pkg.dependencies) { + collectLegacyLockfileEntries(pkg.dependencies); } } else if (typeof pkg === 'object' && pkg !== null) { const p = pkg as Record | undefined>; @@ -1622,6 +1648,10 @@ class AISlopDetector { let utility = 0, quality = 0, style = 0; for (const i of this.issues) { + // Package freshness is tracked separately from AI slop scoring. + if (i.type === 'fresh_package_version') { + continue; + } const w = weights[i.type] || 3; if (i.type.includes('hallucinated') || i.type.includes('todo') || i.type.includes('assumption')) quality += w; else if (i.type.includes('comment') || i.type.includes('redundant') || i.type.includes('boilerplate')) utility += w; diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 74ea5d0..bcd6b14 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -982,6 +982,30 @@ class AISlopDetector { const minAgeDays = this.config.minPackageAgeDays ?? 7; const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; const packageEntries = []; + const collectLegacyLockfileEntries = (dependencies, trail = []) => { + if (typeof dependencies !== 'object' || dependencies === null) { + return; + } + for (const [depName, depInfo] of Object.entries(dependencies)) { + if (typeof depInfo !== 'object' || depInfo === null) { + continue; + } + const info = depInfo; + const version = typeof info.version === 'string' ? info.version : ''; + const pkgName = typeof info.name === 'string' && info.name ? info.name : depName; + const sourceId = ['dependencies', ...trail, depName].join('/'); + if (pkgName && version) { + packageEntries.push({ + sourceId, + pkgName, + version + }); + } + if (info.dependencies && typeof info.dependencies === 'object') { + collectLegacyLockfileEntries(info.dependencies, [...trail, depName]); + } + } + }; try { const pkg = JSON.parse(content); if (filePath.endsWith('package-lock.json')) { @@ -1001,6 +1025,8 @@ class AISlopDetector { }); } } + } else if (pkg.dependencies) { + collectLegacyLockfileEntries(pkg.dependencies); } } else if (typeof pkg === 'object' && pkg !== null) { const p = pkg; @@ -1425,6 +1451,10 @@ class AISlopDetector { quality = 0, style = 0; for (const i of this.issues) { + // Package freshness is tracked separately from AI slop scoring. + if (i.type === 'fresh_package_version') { + continue; + } const w = weights[i.type] || 3; if (i.type.includes('hallucinated') || i.type.includes('todo') || i.type.includes('assumption')) quality += w;else if (i.type.includes('comment') || i.type.includes('redundant') || i.type.includes('boilerplate')) utility += w;else style += w; } From 22c1a35f8ead7a73acff0cfc6b97a55fe9a5bcc7 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 18:05:37 -0400 Subject: [PATCH 27/38] fix: tighten package freshness scans --- ai-slop-detector.ts | 27 ++++++++++++++------------- karpeslop-bin.js | 17 +++++++++-------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index b4ddbd4..5ad7855 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -344,6 +344,7 @@ class AISlopDetector { private customIgnorePaths: string[] = []; private npmPackageCache: Map> = new Map(); private registryWarningLogged = false; + private registryUnavailable = false; private reportedFreshPackageKeys = new Set(); private targetPaths: string[] = []; @@ -569,17 +570,14 @@ class AISlopDetector { private isExcludedPath(filePath: string, allowOutsideRoot: boolean = false): boolean { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); const isOutsideRoot = relativePath.startsWith('..'); - - if (allowOutsideRoot && isOutsideRoot) { - return relativePath.endsWith('.d.ts') || - relativePath.endsWith('ai-slop-detector.ts') || - relativePath.endsWith('improved-ai-slop-detector.ts'); - } + const pathToMatch = allowOutsideRoot && isOutsideRoot + ? path.resolve(filePath).replace(/\\/g, '/') + : relativePath; // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as // a build artifact. A real `dist/` directory under `src/` is rare and // matching it the way users expect is the lesser evil here. - const segments = relativePath.split('/'); + const segments = pathToMatch.split('/'); const excludedSegment = (name: string) => segments.includes(name); return excludedSegment('generated') || excludedSegment('coverage') || @@ -592,9 +590,9 @@ class AISlopDetector { excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || - relativePath.endsWith('.d.ts') || - relativePath.endsWith('ai-slop-detector.ts') || - relativePath.endsWith('improved-ai-slop-detector.ts'); + pathToMatch.endsWith('.d.ts') || + (!isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || + pathToMatch.endsWith('improved-ai-slop-detector.ts'))); } /** @@ -1202,8 +1200,6 @@ class AISlopDetector { if (pkg.packages) { for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { if (pkgPath === '' || pkgPath === 'node_modules/') continue; - // Skip nested node_modules entries to avoid transitive double-counting - if (pkgPath.split('node_modules/').length > 2) continue; const info = pkgInfo as Record; const version = info.version as string; const pkgName = typeof info.name === 'string' && info.name @@ -1250,7 +1246,7 @@ class AISlopDetector { for (const { sourceId, pkgName, version, ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${filePath}|${sourceId}|${pkgName}|${version}`; + const issueKey = `${filePath}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } @@ -1293,6 +1289,10 @@ class AISlopDetector { return { version, time: versionTime, ageMs: now - publishTime }; } + if (this.registryUnavailable) { + return null; + } + let data: { time: Record } | null = null; try { const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; @@ -1311,6 +1311,7 @@ class AISlopDetector { console.warn('⚠️ Could not reach npm registry to check package ages. fresh_package_version checks will be skipped.'); this.registryWarningLogged = true; } + this.registryUnavailable = true; return null; } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index bcd6b14..b58dfff 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -268,6 +268,7 @@ class AISlopDetector { customIgnorePaths = []; npmPackageCache = new Map(); registryWarningLogged = false; + registryUnavailable = false; reportedFreshPackageKeys = new Set(); targetPaths = []; constructor(rootDir, targetPaths) { @@ -452,16 +453,14 @@ class AISlopDetector { isExcludedPath(filePath, allowOutsideRoot = false) { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); const isOutsideRoot = relativePath.startsWith('..'); - if (allowOutsideRoot && isOutsideRoot) { - return relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); - } + const pathToMatch = allowOutsideRoot && isOutsideRoot ? path.resolve(filePath).replace(/\\/g, '/') : relativePath; // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as // a build artifact. A real `dist/` directory under `src/` is rare and // matching it the way users expect is the lesser evil here. - const segments = relativePath.split('/'); + const segments = pathToMatch.split('/'); const excludedSegment = name => segments.includes(name); - return excludedSegment('generated') || excludedSegment('coverage') || excludedSegment('.next') || excludedSegment('node_modules') || excludedSegment('dist') || excludedSegment('build') || excludedSegment('.git') || excludedSegment('out') || excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || relativePath.endsWith('.d.ts') || relativePath.endsWith('ai-slop-detector.ts') || relativePath.endsWith('improved-ai-slop-detector.ts'); + return excludedSegment('generated') || excludedSegment('coverage') || excludedSegment('.next') || excludedSegment('node_modules') || excludedSegment('dist') || excludedSegment('build') || excludedSegment('.git') || excludedSegment('out') || excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || pathToMatch.endsWith('.d.ts') || !isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || pathToMatch.endsWith('improved-ai-slop-detector.ts')); } /** @@ -1012,8 +1011,6 @@ class AISlopDetector { if (pkg.packages) { for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { if (pkgPath === '' || pkgPath === 'node_modules/') continue; - // Skip nested node_modules entries to avoid transitive double-counting - if (pkgPath.split('node_modules/').length > 2) continue; const info = pkgInfo; const version = info.version; const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; @@ -1081,7 +1078,7 @@ class AISlopDetector { ageInfo } of ageInfos) { if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${filePath}|${sourceId}|${pkgName}|${version}`; + const issueKey = `${filePath}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } @@ -1122,6 +1119,9 @@ class AISlopDetector { ageMs: now - publishTime }; } + if (this.registryUnavailable) { + return null; + } let data = null; try { const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; @@ -1142,6 +1142,7 @@ class AISlopDetector { console.warn('⚠️ Could not reach npm registry to check package ages. fresh_package_version checks will be skipped.'); this.registryWarningLogged = true; } + this.registryUnavailable = true; return null; } if (data?.time) { From 89b0de9e7c8023d201ec437c996ce1a41c3993a2 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 19:16:25 -0400 Subject: [PATCH 28/38] fix: tighten quiet and freshness handling --- ai-slop-detector.ts | 69 ++++++++++++++++++++++++++++++++------------- karpeslop-bin.js | 63 +++++++++++++++++++++++++++++------------ 2 files changed, 94 insertions(+), 38 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 5ad7855..b0400ad 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -91,11 +91,12 @@ function parseVersionRange(version: string): { actualVersion: string; isRange: b * outbound HTTP calls to the npm registry (which rate-limits unauthenticated * callers at ~600 req/min). */ -async function pLimit(tasks: (() => Promise)[], concurrency: number): Promise { +async function pLimit(tasks: (() => Promise)[], concurrency: number, shouldStop?: () => boolean): Promise { const results: T[] = new Array(tasks.length); let next = 0; const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => { while (true) { + if (shouldStop?.()) return; const i = next++; if (i >= tasks.length) return; results[i] = await tasks[i](); @@ -500,21 +501,19 @@ class AISlopDetector { let filesToAnalyze: string[]; if (this.targetPaths.length > 0) { - filesToAnalyze = this.resolveTargetPaths(); - console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); - if (filesToAnalyze.length === 0) { + const resolvedTargetFiles = this.resolveTargetPaths(); + if (resolvedTargetFiles.length === 0) { throw new Error('No valid target files found for the supplied paths'); } + filesToAnalyze = quiet + ? resolvedTargetFiles.filter(file => this.shouldAnalyzeInQuietMode(file)) + : resolvedTargetFiles; + console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); } else { const allFiles = this.findAllFiles(); filesToAnalyze = quiet - ? allFiles.filter(file => { - const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); - const base = path.basename(file); - if (base === 'package.json' || base === 'package-lock.json') return true; - return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); - }) + ? allFiles.filter(file => this.shouldAnalyzeInQuietMode(file)) : allFiles; console.log(`πŸ“ Found ${allFiles.length} files to analyze (${filesToAnalyze.length} in ${quiet ? 'quiet' : 'full'} mode)\n`); @@ -555,6 +554,15 @@ class AISlopDetector { ]; } + private shouldAnalyzeInQuietMode(filePath: string): boolean { + const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + const base = path.basename(filePath); + if (base === 'package.json' || base === 'package-lock.json') { + return true; + } + return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); + } + private isIgnoredByConfig(filePath: string): boolean { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); if (relativePath.startsWith('..')) { @@ -1167,7 +1175,20 @@ class AISlopDetector { const minAgeDays = this.config.minPackageAgeDays ?? 7; const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; - const packageEntries: Array<{ sourceId: string; pkgName: string; version: string }> = []; + const packageEntries: Array<{ scopeKey: string; sourceId: string; pkgName: string; version: string }> = []; + const lockfileRoot = path.dirname(filePath); + const normalizePath = (input: string) => input.replace(/\\/g, '/'); + const lockfileScopeKey = (pkgPath: string): string => { + const normalizedPkgPath = normalizePath(pkgPath); + const nodeModulesIndex = normalizedPkgPath.lastIndexOf('/node_modules/'); + const scopeRelative = nodeModulesIndex !== -1 + ? normalizedPkgPath.slice(0, nodeModulesIndex) + : normalizedPkgPath.startsWith('node_modules/') + ? '' + : normalizedPkgPath; + + return normalizePath(path.resolve(lockfileRoot, scopeRelative || '.')); + }; const collectLegacyLockfileEntries = (dependencies: unknown, trail: string[] = []): void => { if (typeof dependencies !== 'object' || dependencies === null) { return; @@ -1181,10 +1202,11 @@ class AISlopDetector { const info = depInfo as Record; const version = typeof info.version === 'string' ? info.version : ''; const pkgName = typeof info.name === 'string' && info.name ? info.name : depName; + const scopeKey = normalizePath(lockfileRoot); const sourceId = ['dependencies', ...trail, depName].join('/'); if (pkgName && version) { - packageEntries.push({ sourceId, pkgName, version }); + packageEntries.push({ scopeKey, sourceId, pkgName, version }); } if (info.dependencies && typeof info.dependencies === 'object') { @@ -1206,18 +1228,19 @@ class AISlopDetector { ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; if (pkgName && version) { - packageEntries.push({ sourceId: pkgPath, pkgName, version }); + packageEntries.push({ scopeKey: lockfileScopeKey(pkgPath), sourceId: pkgPath, pkgName, version }); } } } else if (pkg.dependencies) { collectLegacyLockfileEntries(pkg.dependencies); } } else if (typeof pkg === 'object' && pkg !== null) { + const packageJsonScopeKey = normalizePath(path.dirname(filePath)); const p = pkg as Record | undefined>; for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { if (p[key]) { for (const [pkgName, version] of Object.entries(p[key]!)) { - packageEntries.push({ sourceId: key, pkgName, version }); + packageEntries.push({ scopeKey: packageJsonScopeKey, sourceId: key, pkgName, version }); } } } @@ -1227,7 +1250,7 @@ class AISlopDetector { } const entries = packageEntries - .map(({ sourceId, pkgName, version }) => ({ sourceId, pkgName, ...parseVersionRange(version) })) + .map(({ scopeKey, sourceId, pkgName, version }) => ({ scopeKey, sourceId, pkgName, ...parseVersionRange(version) })) // Keep the rule narrow on purpose: only caret/tilde ranges are treated // as "fresh package" candidates in package.json. package-lock.json is // always checked because it contains the resolved version. @@ -1237,16 +1260,22 @@ class AISlopDetector { // ~600 req/min, and a typical package-lock.json has 200-1000 deps. const NPM_CONCURRENCY = 5; const ageInfos = await pLimit( - entries.map(({ sourceId, pkgName, actualVersion }) => () => + entries.map(({ scopeKey, sourceId, pkgName, actualVersion }) => () => this.getNpmPackageAge(pkgName, actualVersion) - .then(ageInfo => ({ sourceId, pkgName, version: actualVersion, ageInfo })) + .then(ageInfo => ({ scopeKey, sourceId, pkgName, version: actualVersion, ageInfo })) ), - NPM_CONCURRENCY + NPM_CONCURRENCY, + () => this.registryUnavailable ); - for (const { sourceId, pkgName, version, ageInfo } of ageInfos) { + for (const ageInfoResult of ageInfos) { + if (!ageInfoResult) { + continue; + } + + const { scopeKey, sourceId, pkgName, version, ageInfo } = ageInfoResult; if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${filePath}|${pkgName}|${version}`; + const issueKey = `${scopeKey}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index b58dfff..ae266cf 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -41,13 +41,14 @@ function parseVersionRange(version) { * outbound HTTP calls to the npm registry (which rate-limits unauthenticated * callers at ~600 req/min). */ -async function pLimit(tasks, concurrency) { +async function pLimit(tasks, concurrency, shouldStop) { const results = new Array(tasks.length); let next = 0; const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => { while (true) { + if (shouldStop?.()) return; const i = next++; if (i >= tasks.length) return; results[i] = await tasks[i](); @@ -412,19 +413,15 @@ class AISlopDetector { console.log('πŸ” Starting AI Slop detection...\n'); let filesToAnalyze; if (this.targetPaths.length > 0) { - filesToAnalyze = this.resolveTargetPaths(); - console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); - if (filesToAnalyze.length === 0) { + const resolvedTargetFiles = this.resolveTargetPaths(); + if (resolvedTargetFiles.length === 0) { throw new Error('No valid target files found for the supplied paths'); } + filesToAnalyze = quiet ? resolvedTargetFiles.filter(file => this.shouldAnalyzeInQuietMode(file)) : resolvedTargetFiles; + console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); } else { const allFiles = this.findAllFiles(); - filesToAnalyze = quiet ? allFiles.filter(file => { - const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); - const base = path.basename(file); - if (base === 'package.json' || base === 'package-lock.json') return true; - return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); - }) : allFiles; + filesToAnalyze = quiet ? allFiles.filter(file => this.shouldAnalyzeInQuietMode(file)) : allFiles; console.log(`πŸ“ Found ${allFiles.length} files to analyze (${filesToAnalyze.length} in ${quiet ? 'quiet' : 'full'} mode)\n`); } @@ -440,6 +437,14 @@ class AISlopDetector { getGlobIgnorePatterns() { return ['node_modules/**', '.next/**', 'dist/**', 'build/**', 'coverage/**', 'generated/**', '.vercel/**', '.git/**', '**/types/**', '**/node_modules/**', '**/.*', '**/*.d.ts', '**/coverage/**', '**/out/**', '**/temp/**', 'scripts/ai-slop-detector.ts', 'ai-slop-detector.ts', 'improved-ai-slop-detector.ts', ...this.customIgnorePaths]; } + shouldAnalyzeInQuietMode(filePath) { + const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); + const base = path.basename(filePath); + if (base === 'package.json' || base === 'package-lock.json') { + return true; + } + return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); + } isIgnoredByConfig(filePath) { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); if (relativePath.startsWith('..')) { @@ -981,6 +986,14 @@ class AISlopDetector { const minAgeDays = this.config.minPackageAgeDays ?? 7; const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; const packageEntries = []; + const lockfileRoot = path.dirname(filePath); + const normalizePath = input => input.replace(/\\/g, '/'); + const lockfileScopeKey = pkgPath => { + const normalizedPkgPath = normalizePath(pkgPath); + const nodeModulesIndex = normalizedPkgPath.lastIndexOf('/node_modules/'); + const scopeRelative = nodeModulesIndex !== -1 ? normalizedPkgPath.slice(0, nodeModulesIndex) : normalizedPkgPath.startsWith('node_modules/') ? '' : normalizedPkgPath; + return normalizePath(path.resolve(lockfileRoot, scopeRelative || '.')); + }; const collectLegacyLockfileEntries = (dependencies, trail = []) => { if (typeof dependencies !== 'object' || dependencies === null) { return; @@ -992,9 +1005,11 @@ class AISlopDetector { const info = depInfo; const version = typeof info.version === 'string' ? info.version : ''; const pkgName = typeof info.name === 'string' && info.name ? info.name : depName; + const scopeKey = normalizePath(lockfileRoot); const sourceId = ['dependencies', ...trail, depName].join('/'); if (pkgName && version) { packageEntries.push({ + scopeKey, sourceId, pkgName, version @@ -1016,6 +1031,7 @@ class AISlopDetector { const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; if (pkgName && version) { packageEntries.push({ + scopeKey: lockfileScopeKey(pkgPath), sourceId: pkgPath, pkgName, version @@ -1026,11 +1042,13 @@ class AISlopDetector { collectLegacyLockfileEntries(pkg.dependencies); } } else if (typeof pkg === 'object' && pkg !== null) { + const packageJsonScopeKey = normalizePath(path.dirname(filePath)); const p = pkg; for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { if (p[key]) { for (const [pkgName, version] of Object.entries(p[key])) { packageEntries.push({ + scopeKey: packageJsonScopeKey, sourceId: key, pkgName, version @@ -1043,10 +1061,12 @@ class AISlopDetector { return; } const entries = packageEntries.map(({ + scopeKey, sourceId, pkgName, version }) => ({ + scopeKey, sourceId, pkgName, ...parseVersionRange(version) @@ -1062,23 +1082,30 @@ class AISlopDetector { // ~600 req/min, and a typical package-lock.json has 200-1000 deps. const NPM_CONCURRENCY = 5; const ageInfos = await pLimit(entries.map(({ + scopeKey, sourceId, pkgName, actualVersion }) => () => this.getNpmPackageAge(pkgName, actualVersion).then(ageInfo => ({ + scopeKey, sourceId, pkgName, version: actualVersion, ageInfo - }))), NPM_CONCURRENCY); - for (const { - sourceId, - pkgName, - version, - ageInfo - } of ageInfos) { + }))), NPM_CONCURRENCY, () => this.registryUnavailable); + for (const ageInfoResult of ageInfos) { + if (!ageInfoResult) { + continue; + } + const { + scopeKey, + sourceId, + pkgName, + version, + ageInfo + } = ageInfoResult; if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${filePath}|${pkgName}|${version}`; + const issueKey = `${scopeKey}|${pkgName}|${version}`; if (this.reportedFreshPackageKeys.has(issueKey)) { continue; } From d0314a5bff09a9a60b4b3c9a54c76b43e81bf216 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 19:50:12 -0400 Subject: [PATCH 29/38] fix: ignore workspace lockfile entries --- ai-slop-detector.ts | 8 +++++++- karpeslop-bin.js | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index b0400ad..9379581 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1222,6 +1222,10 @@ class AISlopDetector { if (pkg.packages) { for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { if (pkgPath === '' || pkgPath === 'node_modules/') continue; + // Workspace entries like `packages/ui` are local packages, not + // registry-installed dependencies, so they should not be checked + // for package freshness. + if (!pkgPath.includes('node_modules/')) continue; const info = pkgInfo as Record; const version = info.version as string; const pkgName = typeof info.name === 'string' && info.name @@ -1507,9 +1511,11 @@ class AISlopDetector { console.log(`Style / Taste (Soul) : ${score.style} pts`); console.log(`TOTAL KARPE-SLOP SCORE : ${score.total} pts`); - if (score.total === 0) { + if (this.issues.length === 0) { console.log(`\nCLEAN. Even Andrej would approve.`); console.log(` "This codebase has taste." β€” @karpathy, probably`); + } else if (this.issues.every(issue => issue.type === 'fresh_package_version')) { + console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); } else if (score.total > 50) { console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); } else { diff --git a/karpeslop-bin.js b/karpeslop-bin.js index ae266cf..51304b2 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -1026,6 +1026,10 @@ class AISlopDetector { if (pkg.packages) { for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { if (pkgPath === '' || pkgPath === 'node_modules/') continue; + // Workspace entries like `packages/ui` are local packages, not + // registry-installed dependencies, so they should not be checked + // for package freshness. + if (!pkgPath.includes('node_modules/')) continue; const info = pkgInfo; const version = info.version; const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; @@ -1320,9 +1324,11 @@ class AISlopDetector { console.log(`Information Quality (Lies) : ${score.informationQuality} pts`); console.log(`Style / Taste (Soul) : ${score.style} pts`); console.log(`TOTAL KARPE-SLOP SCORE : ${score.total} pts`); - if (score.total === 0) { + if (this.issues.length === 0) { console.log(`\nCLEAN. Even Andrej would approve.`); console.log(` "This codebase has taste." β€” @karpathy, probably`); + } else if (this.issues.every(issue => issue.type === 'fresh_package_version')) { + console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); } else if (score.total > 50) { console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); } else { From 1027563112d770596c2f393b4e5db7ba334fd9c6 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 20:02:12 -0400 Subject: [PATCH 30/38] test: add mock-free behavior coverage --- ai-slop-detector.ts | 44 ++++++++++++++++--------- karpeslop-bin.js | 39 +++++++++++++--------- package.json | 1 + tests/ai-slop-detector.behavior.test.ts | 34 +++++++++++++++++++ 4 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 tests/ai-slop-detector.behavior.test.ts diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 9379581..611929c 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -106,6 +106,25 @@ async function pLimit(tasks: (() => Promise)[], concurrency: number, shoul return results; } +export function shouldAnalyzePathInQuietMode(filePath: string, rootDir: string, coreAppDirs: readonly string[]): boolean { + const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); + const base = path.basename(filePath); + if (base === 'package.json' || base === 'package-lock.json') { + return true; + } + return coreAppDirs.some(dir => relativePath.startsWith(dir)); +} + +export function isRegistryBackedLockfileEntry(pkgPath: string): boolean { + return pkgPath.includes('node_modules/'); +} + +export function hasOnlyFreshPackageWarnings( + issues: ReadonlyArray> +): boolean { + return issues.length > 0 && issues.every(issue => issue.type === 'fresh_package_version'); +} + class AISlopDetector { private issues: AISlopIssue[] = []; private targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -555,12 +574,7 @@ class AISlopDetector { } private shouldAnalyzeInQuietMode(filePath: string): boolean { - const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - const base = path.basename(filePath); - if (base === 'package.json' || base === 'package-lock.json') { - return true; - } - return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); + return shouldAnalyzePathInQuietMode(filePath, this.rootDir, this.coreAppDirs); } private isIgnoredByConfig(filePath: string): boolean { @@ -1225,7 +1239,7 @@ class AISlopDetector { // Workspace entries like `packages/ui` are local packages, not // registry-installed dependencies, so they should not be checked // for package freshness. - if (!pkgPath.includes('node_modules/')) continue; + if (!isRegistryBackedLockfileEntry(pkgPath)) continue; const info = pkgInfo as Record; const version = info.version as string; const pkgName = typeof info.name === 'string' && info.name @@ -1514,7 +1528,7 @@ class AISlopDetector { if (this.issues.length === 0) { console.log(`\nCLEAN. Even Andrej would approve.`); console.log(` "This codebase has taste." β€” @karpathy, probably`); - } else if (this.issues.every(issue => issue.type === 'fresh_package_version')) { + } else if (hasOnlyFreshPackageWarnings(this.issues)) { console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); } else if (score.total > 50) { console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); @@ -1801,12 +1815,12 @@ The tool detects the three axes of AI slop: } } -// Execute as CLI tool - this file is designed to be run as a command-line tool -// The complex main module check has caused issues with npm wrapper scripts -// Since this is a CLI tool (not a module to be imported), just run when executed -runIfMain().catch(error => { - console.error('πŸ’₯ AI Slop detection failed:', error); - process.exit(1); -}); +// Execute only when run as the main module, not when imported by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + runIfMain().catch(error => { + console.error('πŸ’₯ AI Slop detection failed:', error); + process.exit(1); + }); +} export { AISlopDetector, AISlopIssue, ConsolidatedIssue, DetectionPattern }; diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 51304b2..b31e074 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -57,6 +57,20 @@ async function pLimit(tasks, concurrency, shouldStop) { await Promise.all(workers); return results; } +export function shouldAnalyzePathInQuietMode(filePath, rootDir, coreAppDirs) { + const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); + const base = path.basename(filePath); + if (base === 'package.json' || base === 'package-lock.json') { + return true; + } + return coreAppDirs.some(dir => relativePath.startsWith(dir)); +} +export function isRegistryBackedLockfileEntry(pkgPath) { + return pkgPath.includes('node_modules/'); +} +export function hasOnlyFreshPackageWarnings(issues) { + return issues.length > 0 && issues.every(issue => issue.type === 'fresh_package_version'); +} class AISlopDetector { issues = []; targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -438,12 +452,7 @@ class AISlopDetector { return ['node_modules/**', '.next/**', 'dist/**', 'build/**', 'coverage/**', 'generated/**', '.vercel/**', '.git/**', '**/types/**', '**/node_modules/**', '**/.*', '**/*.d.ts', '**/coverage/**', '**/out/**', '**/temp/**', 'scripts/ai-slop-detector.ts', 'ai-slop-detector.ts', 'improved-ai-slop-detector.ts', ...this.customIgnorePaths]; } shouldAnalyzeInQuietMode(filePath) { - const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - const base = path.basename(filePath); - if (base === 'package.json' || base === 'package-lock.json') { - return true; - } - return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); + return shouldAnalyzePathInQuietMode(filePath, this.rootDir, this.coreAppDirs); } isIgnoredByConfig(filePath) { const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); @@ -1029,7 +1038,7 @@ class AISlopDetector { // Workspace entries like `packages/ui` are local packages, not // registry-installed dependencies, so they should not be checked // for package freshness. - if (!pkgPath.includes('node_modules/')) continue; + if (!isRegistryBackedLockfileEntry(pkgPath)) continue; const info = pkgInfo; const version = info.version; const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; @@ -1327,7 +1336,7 @@ class AISlopDetector { if (this.issues.length === 0) { console.log(`\nCLEAN. Even Andrej would approve.`); console.log(` "This codebase has taste." β€” @karpathy, probably`); - } else if (this.issues.every(issue => issue.type === 'fresh_package_version')) { + } else if (hasOnlyFreshPackageWarnings(this.issues)) { console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); } else if (score.total > 50) { console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); @@ -1599,11 +1608,11 @@ The tool detects the three axes of AI slop: } } -// Execute as CLI tool - this file is designed to be run as a command-line tool -// The complex main module check has caused issues with npm wrapper scripts -// Since this is a CLI tool (not a module to be imported), just run when executed -runIfMain().catch(error => { - console.error('πŸ’₯ AI Slop detection failed:', error); - process.exit(1); -}); +// Execute only when run as the main module, not when imported by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + runIfMain().catch(error => { + console.error('πŸ’₯ AI Slop detection failed:', error); + process.exit(1); + }); +} export { AISlopDetector }; diff --git a/package.json b/package.json index ba36286..3a7d1fe 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "build": "babel ai-slop-detector.ts --out-file karpeslop-bin.js --extensions \".ts,.tsx\"", + "test": "node --import tsx --test tests/*.test.ts", "slop": "tsx ai-slop-detector.ts", "slop:quiet": "tsx ai-slop-detector.ts --quiet", "slop:report": "tsx ai-slop-detector.ts && cat ai-slop-report.json" diff --git a/tests/ai-slop-detector.behavior.test.ts b/tests/ai-slop-detector.behavior.test.ts new file mode 100644 index 0000000..34a53bf --- /dev/null +++ b/tests/ai-slop-detector.behavior.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + hasOnlyFreshPackageWarnings, + isRegistryBackedLockfileEntry, + shouldAnalyzePathInQuietMode +} from '../ai-slop-detector.ts'; + +test('quiet mode still filters explicit paths to core app files and manifests', () => { + const rootDir = '/repo'; + const coreAppDirs = ['app/', 'components/', 'lib/', 'hooks/', 'services/']; + + assert.equal(shouldAnalyzePathInQuietMode('/repo/app/inside.ts', rootDir, coreAppDirs), true); + assert.equal(shouldAnalyzePathInQuietMode('/repo/src/outside.ts', rootDir, coreAppDirs), false); + assert.equal(shouldAnalyzePathInQuietMode('/repo/package.json', rootDir, coreAppDirs), true); +}); + +test('workspace-local lockfile entries are not treated like registry dependencies', () => { + assert.equal(isRegistryBackedLockfileEntry('packages/ui'), false); + assert.equal(isRegistryBackedLockfileEntry('node_modules/parent/node_modules/lodash'), true); +}); + +test('fresh-package warnings do not trigger the clean banner state', () => { + assert.equal(hasOnlyFreshPackageWarnings([]), false); + assert.equal(hasOnlyFreshPackageWarnings([{ type: 'fresh_package_version' }]), true); + assert.equal( + hasOnlyFreshPackageWarnings([ + { type: 'fresh_package_version' }, + { type: 'any_type_usage' } + ]), + false + ); +}); From cbd5e42bb82a8903a08b04230d4eec62bab3dae0 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Tue, 9 Jun 2026 20:10:02 -0400 Subject: [PATCH 31/38] test: add mock-free behavior coverage for --strict, -- separator, segment-based exclusions, and parseVersionRange scope --- ai-slop-detector.ts | 103 ++++++++++----------- karpeslop-bin.js | 73 ++++++++------- tests/ai-slop-detector.behavior.test.ts | 117 +++++++++++++++++++++++- 3 files changed, 208 insertions(+), 85 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 611929c..96b2f64 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -79,7 +79,7 @@ interface KarpeSlopConfig { * check because those are the common semver ranges that drift on install. * Broader operators like `>=`, `1.x`, or `latest` are not resolved here. */ -function parseVersionRange(version: string): { actualVersion: string; isRange: boolean } { +export function parseVersionRange(version: string): { actualVersion: string; isRange: boolean } { if (version.startsWith('^') || version.startsWith('~')) { return { actualVersion: version.slice(1), isRange: true }; } @@ -125,6 +125,38 @@ export function hasOnlyFreshPackageWarnings( return issues.length > 0 && issues.every(issue => issue.type === 'fresh_package_version'); } +/** + * Determine whether a file path should be excluded from analysis. + * + * Segment-based matching means any file under e.g. `src/dist/` is still excluded + * because `dist` exists as a segment. Matching `dist` everywhere is the simpler, + * reliable choice for a build-artifact filter. + */ +export function isExcludedPath(filePath: string, rootDir: string, allowOutsideRoot: boolean = false): boolean { + const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); + const isOutsideRoot = relativePath.startsWith('..'); + const pathToMatch = allowOutsideRoot && isOutsideRoot + ? path.resolve(filePath).replace(/\\/g, '/') + : relativePath; + + const segments = pathToMatch.split('/'); + const excludedSegment = (name: string) => segments.includes(name); + return excludedSegment('generated') || + excludedSegment('coverage') || + excludedSegment('.next') || + excludedSegment('node_modules') || + excludedSegment('dist') || + excludedSegment('build') || + excludedSegment('.git') || + excludedSegment('out') || + excludedSegment('temp') || + excludedSegment('types') || + segments.some(segment => segment.startsWith('.')) || + pathToMatch.endsWith('.d.ts') || + (!isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || + pathToMatch.endsWith('improved-ai-slop-detector.ts'))); +} + class AISlopDetector { private issues: AISlopIssue[] = []; private targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -589,34 +621,6 @@ class AISlopDetector { }).length === 0; } - private isExcludedPath(filePath: string, allowOutsideRoot: boolean = false): boolean { - const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - const isOutsideRoot = relativePath.startsWith('..'); - const pathToMatch = allowOutsideRoot && isOutsideRoot - ? path.resolve(filePath).replace(/\\/g, '/') - : relativePath; - - // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as - // a build artifact. A real `dist/` directory under `src/` is rare and - // matching it the way users expect is the lesser evil here. - const segments = pathToMatch.split('/'); - const excludedSegment = (name: string) => segments.includes(name); - return excludedSegment('generated') || - excludedSegment('coverage') || - excludedSegment('.next') || - excludedSegment('node_modules') || - excludedSegment('dist') || - excludedSegment('build') || - excludedSegment('.git') || - excludedSegment('out') || - excludedSegment('temp') || - excludedSegment('types') || - segments.some(segment => segment.startsWith('.')) || - pathToMatch.endsWith('.d.ts') || - (!isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || - pathToMatch.endsWith('improved-ai-slop-detector.ts'))); - } - /** * Find all TypeScript/JavaScript files in the project, plus manifest files */ @@ -626,7 +630,7 @@ class AISlopDetector { for (const ext of this.targetExtensions) { const pattern = path.join(this.rootDir, `**/*${ext}`).replace(/\\/g, '/'); const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); - const filteredFiles = files.filter(file => !this.isExcludedPath(file)); + const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...filteredFiles); } @@ -636,11 +640,11 @@ class AISlopDetector { const rootManifestPath = path.join(this.rootDir, name); const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); const manifestFiles = [ - ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath) + ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) - ].filter(file => !this.isExcludedPath(file)); + ].filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...manifestFiles); } @@ -669,7 +673,7 @@ class AISlopDetector { const isManifest = ext === '.json' && this.manifestFilenames.includes(base); if (this.isIgnoredByConfig(targetPath)) { console.warn(`⚠️ Target file matches ignore paths, skipping: ${targetPath}`); - } else if (this.isExcludedPath(targetPath, true)) { + } else if (isExcludedPath(targetPath, this.rootDir, true)) { console.warn(`⚠️ Target file is in an excluded path, skipping: ${targetPath}`); } else if (this.targetExtensions.includes(ext) || isManifest) { resolved.push(targetPath); @@ -680,7 +684,7 @@ class AISlopDetector { for (const ext of this.targetExtensions) { const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); - const filteredFiles = files.filter(file => !this.isExcludedPath(file, true)); + const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...filteredFiles); } for (const manifestName of this.manifestFilenames) { @@ -689,11 +693,11 @@ class AISlopDetector { const rootManifestPath = path.join(targetPath, manifestName); const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); const manifestFiles = [ - ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath, true) + ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir, true) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) - ].filter(file => !this.isExcludedPath(file, true)); + ].filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...manifestFiles); } } @@ -1715,23 +1719,20 @@ class AISlopDetector { } // Run the detector if this script is executed directly -async function runIfMain() { - const rootDir = process.cwd(); - - // Parse command line arguments +export function splitCliArgs(args: readonly string[]): { flagArgs: string[]; targetPaths: string[] } { // Support -- separator: everything after -- is treated as a path, even if it starts with - - const args = process.argv.slice(2); const doubleDashIdx = args.indexOf('--'); - let flagArgs: string[]; - let targetPaths: string[]; - if (doubleDashIdx !== -1) { - flagArgs = args.slice(0, doubleDashIdx); - targetPaths = args.slice(doubleDashIdx + 1); - } else { - flagArgs = args.filter(a => a.startsWith('-')); - targetPaths = args.filter(a => !a.startsWith('-')); + return { flagArgs: args.slice(0, doubleDashIdx), targetPaths: args.slice(doubleDashIdx + 1) }; } + return { flagArgs: args.filter(a => a.startsWith('-')), targetPaths: args.filter(a => !a.startsWith('-')) }; +} + +async function runIfMain() { + const rootDir = process.cwd(); + + const args = process.argv.slice(2); + const { flagArgs, targetPaths } = splitCliArgs(args); // Check for help options first, before constructing the detector // (so a bad config doesn't break --help) @@ -1786,8 +1787,8 @@ The tool detects the three axes of AI slop: } const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); - const quiet = flagArgs.includes('--quiet') || flagArgs.includes('-q'); - const strict = flagArgs.includes('--strict') || flagArgs.includes('-s') || !!detector.getConfig().blockOnCritical; + const quiet = flagArgs.includes('--quiet') || flagArgs.includes('-q'); + const strict = flagArgs.includes('--strict') || flagArgs.includes('-s') || !!detector.getConfig().blockOnCritical; try { const issues = await detector.detect(quiet); diff --git a/karpeslop-bin.js b/karpeslop-bin.js index b31e074..28916ca 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -23,7 +23,7 @@ import { fileURLToPath } from 'url'; * check because those are the common semver ranges that drift on install. * Broader operators like `>=`, `1.x`, or `latest` are not resolved here. */ -function parseVersionRange(version) { +export function parseVersionRange(version) { if (version.startsWith('^') || version.startsWith('~')) { return { actualVersion: version.slice(1), @@ -71,6 +71,22 @@ export function isRegistryBackedLockfileEntry(pkgPath) { export function hasOnlyFreshPackageWarnings(issues) { return issues.length > 0 && issues.every(issue => issue.type === 'fresh_package_version'); } + +/** + * Determine whether a file path should be excluded from analysis. + * + * Segment-based matching means any file under e.g. `src/dist/` is still excluded + * because `dist` exists as a segment. Matching `dist` everywhere is the simpler, + * reliable choice for a build-artifact filter. + */ +export function isExcludedPath(filePath, rootDir, allowOutsideRoot = false) { + const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); + const isOutsideRoot = relativePath.startsWith('..'); + const pathToMatch = allowOutsideRoot && isOutsideRoot ? path.resolve(filePath).replace(/\\/g, '/') : relativePath; + const segments = pathToMatch.split('/'); + const excludedSegment = name => segments.includes(name); + return excludedSegment('generated') || excludedSegment('coverage') || excludedSegment('.next') || excludedSegment('node_modules') || excludedSegment('dist') || excludedSegment('build') || excludedSegment('.git') || excludedSegment('out') || excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || pathToMatch.endsWith('.d.ts') || !isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || pathToMatch.endsWith('improved-ai-slop-detector.ts')); +} class AISlopDetector { issues = []; targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -464,18 +480,6 @@ class AISlopDetector { ignore: this.getGlobIgnorePatterns() }).length === 0; } - isExcludedPath(filePath, allowOutsideRoot = false) { - const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - const isOutsideRoot = relativePath.startsWith('..'); - const pathToMatch = allowOutsideRoot && isOutsideRoot ? path.resolve(filePath).replace(/\\/g, '/') : relativePath; - - // Use segment-based matching so e.g. `src/dist/foo.ts` isn't treated as - // a build artifact. A real `dist/` directory under `src/` is rare and - // matching it the way users expect is the lesser evil here. - const segments = pathToMatch.split('/'); - const excludedSegment = name => segments.includes(name); - return excludedSegment('generated') || excludedSegment('coverage') || excludedSegment('.next') || excludedSegment('node_modules') || excludedSegment('dist') || excludedSegment('build') || excludedSegment('.git') || excludedSegment('out') || excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || pathToMatch.endsWith('.d.ts') || !isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || pathToMatch.endsWith('improved-ai-slop-detector.ts')); - } /** * Find all TypeScript/JavaScript files in the project, plus manifest files @@ -487,7 +491,7 @@ class AISlopDetector { const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); - const filteredFiles = files.filter(file => !this.isExcludedPath(file)); + const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...filteredFiles); } @@ -496,9 +500,9 @@ class AISlopDetector { for (const name of this.manifestFilenames) { const rootManifestPath = path.join(this.rootDir, name); const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() - })].filter(file => !this.isExcludedPath(file)); + })].filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...manifestFiles); } @@ -524,7 +528,7 @@ class AISlopDetector { const isManifest = ext === '.json' && this.manifestFilenames.includes(base); if (this.isIgnoredByConfig(targetPath)) { console.warn(`⚠️ Target file matches ignore paths, skipping: ${targetPath}`); - } else if (this.isExcludedPath(targetPath, true)) { + } else if (isExcludedPath(targetPath, this.rootDir, true)) { console.warn(`⚠️ Target file is in an excluded path, skipping: ${targetPath}`); } else if (this.targetExtensions.includes(ext) || isManifest) { resolved.push(targetPath); @@ -537,7 +541,7 @@ class AISlopDetector { const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); - const filteredFiles = files.filter(file => !this.isExcludedPath(file, true)); + const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...filteredFiles); } for (const manifestName of this.manifestFilenames) { @@ -545,9 +549,9 @@ class AISlopDetector { // fresh_package_version rule still fires in monorepos/workspaces. const rootManifestPath = path.join(targetPath, manifestName); const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !this.isExcludedPath(rootManifestPath, true) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir, true) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() - })].filter(file => !this.isExcludedPath(file, true)); + })].filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...manifestFiles); } } @@ -1512,22 +1516,27 @@ class AISlopDetector { } // Run the detector if this script is executed directly -async function runIfMain() { - const rootDir = process.cwd(); - - // Parse command line arguments +export function splitCliArgs(args) { // Support -- separator: everything after -- is treated as a path, even if it starts with - - const args = process.argv.slice(2); const doubleDashIdx = args.indexOf('--'); - let flagArgs; - let targetPaths; if (doubleDashIdx !== -1) { - flagArgs = args.slice(0, doubleDashIdx); - targetPaths = args.slice(doubleDashIdx + 1); - } else { - flagArgs = args.filter(a => a.startsWith('-')); - targetPaths = args.filter(a => !a.startsWith('-')); + return { + flagArgs: args.slice(0, doubleDashIdx), + targetPaths: args.slice(doubleDashIdx + 1) + }; } + return { + flagArgs: args.filter(a => a.startsWith('-')), + targetPaths: args.filter(a => !a.startsWith('-')) + }; +} +async function runIfMain() { + const rootDir = process.cwd(); + const args = process.argv.slice(2); + const { + flagArgs, + targetPaths + } = splitCliArgs(args); // Check for help options first, before constructing the detector // (so a bad config doesn't break --help) diff --git a/tests/ai-slop-detector.behavior.test.ts b/tests/ai-slop-detector.behavior.test.ts index 34a53bf..018d47e 100644 --- a/tests/ai-slop-detector.behavior.test.ts +++ b/tests/ai-slop-detector.behavior.test.ts @@ -1,12 +1,22 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; import { hasOnlyFreshPackageWarnings, + isExcludedPath, isRegistryBackedLockfileEntry, - shouldAnalyzePathInQuietMode + parseVersionRange, + shouldAnalyzePathInQuietMode, + splitCliArgs, } from '../ai-slop-detector.ts'; +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + test('quiet mode still filters explicit paths to core app files and manifests', () => { const rootDir = '/repo'; const coreAppDirs = ['app/', 'components/', 'lib/', 'hooks/', 'services/']; @@ -27,8 +37,111 @@ test('fresh-package warnings do not trigger the clean banner state', () => { assert.equal( hasOnlyFreshPackageWarnings([ { type: 'fresh_package_version' }, - { type: 'any_type_usage' } + { type: 'any_type_usage' }, ]), false ); }); + +test('isExcludedPath uses segment-based matching so dist anywhere in path is excluded', () => { + const rootDir = '/repo'; + + // dist/ anywhere in the segment list is excluded + assert.equal(isExcludedPath('/repo/dist/utils.ts', rootDir), true); + assert.equal(isExcludedPath('/repo/src/dist/utils.ts', rootDir), true); + assert.equal(isExcludedPath('/repo/packages/core/dist/index.ts', rootDir), true); + + // Other exclusions still fire + assert.equal(isExcludedPath('/repo/node_modules/lodash/index.ts', rootDir), true); + assert.equal(isExcludedPath('/repo/coverage/lcov-report/index.ts', rootDir), true); + assert.equal(isExcludedPath('/repo/src/components/Button.tsx', rootDir), false); +}); + +test('isExcludedPath with allowOutsideRoot preserves targets outside repo root', () => { + const rootDir = '/repo'; + + // Outside repo root β†’ relativePath starts with ".." + // With allowOutsideRoot=true, it resolves absolute path and checks segments. + // /other/project/dist/foo.ts contains "dist" β†’ excluded. + assert.equal(isExcludedPath('/other/project/dist/foo.ts', rootDir, true), true); + + // /other/project/app/foo.ts has no excluded segment β†’ preserved. + assert.equal(isExcludedPath('/other/project/app/foo.ts', rootDir, true), false); +}); + +test('parseVersionRange intentionally only handles caret and tilde', () => { + assert.deepStrictEqual(parseVersionRange('^1.2.3'), { actualVersion: '1.2.3', isRange: true }); + assert.deepStrictEqual(parseVersionRange('~4.5.6'), { actualVersion: '4.5.6', isRange: true }); + + // Exact versions are not treated as ranges + assert.deepStrictEqual(parseVersionRange('2.0.0'), { actualVersion: '2.0.0', isRange: false }); + + // Broader operators (explicitly not handled) + assert.deepStrictEqual(parseVersionRange('>=1.0.0'), { actualVersion: '>=1.0.0', isRange: false }); + assert.deepStrictEqual(parseVersionRange('1.x'), { actualVersion: '1.x', isRange: false }); + assert.deepStrictEqual(parseVersionRange('latest'), { actualVersion: 'latest', isRange: false }); + assert.deepStrictEqual(parseVersionRange('*'), { actualVersion: '*', isRange: false }); +}); + +test('splitCliArgs respects -- separator and prevents flag-like paths from being treated as options', () => { + assert.deepStrictEqual(splitCliArgs(['--quiet', '--']), { flagArgs: ['--quiet'], targetPaths: [] }); + assert.deepStrictEqual(splitCliArgs(['--quiet', '--', '-my-file.ts']), { + flagArgs: ['--quiet'], + targetPaths: ['-my-file.ts'], + }); + assert.deepStrictEqual(splitCliArgs(['--quiet', 'src/app.ts']), { + flagArgs: ['--quiet'], + targetPaths: ['src/app.ts'], + }); + assert.deepStrictEqual(splitCliArgs(['src/app.ts']), { flagArgs: [], targetPaths: ['src/app.ts'] }); + // Without --, anything starting with - is treated as a flag. This is expected. + assert.deepStrictEqual(splitCliArgs(['-dash-file.ts']), { flagArgs: ['-dash-file.ts'], targetPaths: [] }); +}); + +// --------------------------------------------------------------------------- +// Integration: CLI exit codes +// --------------------------------------------------------------------------- + +test('--strict exits with code 2 when critical hallucination is found', () => { + const tmpDir = fs.mkdtempSync(path.join(process.platform === 'win32' ? process.env.TEMP! : '/tmp', 'karpeslop-strict-')); + const fixtureFile = path.join(tmpDir, 'hallucinated.tsx'); + + // The hallucinated_react_import pattern triggers a critical issue + fs.writeFileSync(fixtureFile, "import { useRouter } from 'react';\n", 'utf-8'); + + const result = spawnSync( + process.execPath, + ['--import', 'tsx', path.resolve(process.cwd(), 'ai-slop-detector.ts'), '--strict', fixtureFile], + { encoding: 'utf-8', cwd: process.cwd() } + ); + + try { + assert.equal(result.status, 2, `Expected exit code 2 but got ${result.status}. stdout:"${result.stdout}" stderr:"${result.stderr}"`); + } finally { + fs.unlinkSync(fixtureFile); + fs.rmdirSync(tmpDir); + } +}); + +test('-- separator lets paths starting with - be treated as targets, not flags', () => { + const tmpDir = fs.mkdtempSync(path.join(process.platform === 'win32' ? process.env.TEMP! : '/tmp', 'karpeslop-dash-')); + const fixtureFile = path.join(tmpDir, '-my-file.ts'); + + // Write a file with an any-type so there's actual slop to report. + // We assert that the file name appears in stdout, proving it was scanned and not dropped. + fs.writeFileSync(fixtureFile, 'const x: any = 1;\n', 'utf-8'); + + const result = spawnSync( + process.execPath, + ['--import', 'tsx', path.resolve(process.cwd(), 'ai-slop-detector.ts'), '--', fixtureFile], + { encoding: 'utf-8', cwd: process.cwd() } + ); + + try { + assert.equal(result.status, 1, `Expected exit code 1 (slop found) but got ${result.status}. stdout:"${result.stdout}" stderr:"${result.stderr}"`); + assert.ok(result.stdout.includes(fixtureFile) || result.stdout.includes('-my-file.ts'), 'stdout should mention the scanned file name, proving it was treated as a target'); + } finally { + fs.unlinkSync(fixtureFile); + fs.rmdirSync(tmpDir); + } +}); From 18bedd81e8c5e0965ee5859e33f4d177a54bcda4 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 12:18:55 -0400 Subject: [PATCH 32/38] fix: use path.basename instead of endsWith for manifest file detection --- ai-slop-detector.ts | 2 +- karpeslop-bin.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 96b2f64..65c7b06 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1039,7 +1039,7 @@ class AISlopDetector { } // Special handling for package.json and package-lock.json to detect fresh package versions - if (filePath.endsWith('package.json') || filePath.endsWith('package-lock.json')) { + if (path.basename(filePath) === 'package.json' || path.basename(filePath) === 'package-lock.json') { await this.analyzePackageVersions(filePath, content); } } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 28916ca..d08e2a6 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -854,7 +854,7 @@ class AISlopDetector { } // Special handling for package.json and package-lock.json to detect fresh package versions - if (filePath.endsWith('package.json') || filePath.endsWith('package-lock.json')) { + if (path.basename(filePath) === 'package.json' || path.basename(filePath) === 'package-lock.json') { await this.analyzePackageVersions(filePath, content); } } From 1fee7b4b0115fae37795586a3764653e0301e3a1 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 12:23:12 -0400 Subject: [PATCH 33/38] fix: exit 0 when only fresh-package warnings found, matching banner messaging --- ai-slop-detector.ts | 2 +- karpeslop-bin.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 65c7b06..58b16c3 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1808,7 +1808,7 @@ The tool detects the three axes of AI slop: process.exit(2); } - const exitCode = issues.length > 0 ? 1 : 0; + const exitCode = hasOnlyFreshPackageWarnings(issues) ? 0 : issues.length > 0 ? 1 : 0; process.exit(exitCode); } catch (error) { console.error('πŸ’₯ AI Slop detection failed:', error); diff --git a/karpeslop-bin.js b/karpeslop-bin.js index d08e2a6..f4fb675 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -1609,7 +1609,7 @@ The tool detects the three axes of AI slop: } process.exit(2); } - const exitCode = issues.length > 0 ? 1 : 0; + const exitCode = hasOnlyFreshPackageWarnings(issues) ? 0 : issues.length > 0 ? 1 : 0; process.exit(exitCode); } catch (error) { console.error('πŸ’₯ AI Slop detection failed:', error); From 6d505058f2fdd126ef8f7a8a285e50107f193e40 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 12:32:01 -0400 Subject: [PATCH 34/38] fix: skip null package entries in lockfile v3 to prevent silent analysis skip --- ai-slop-detector.ts | 1 + karpeslop-bin.js | 1 + 2 files changed, 2 insertions(+) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index 58b16c3..e0dd05b 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1244,6 +1244,7 @@ class AISlopDetector { // registry-installed dependencies, so they should not be checked // for package freshness. if (!isRegistryBackedLockfileEntry(pkgPath)) continue; + if (pkgInfo === null || pkgInfo === undefined) continue; const info = pkgInfo as Record; const version = info.version as string; const pkgName = typeof info.name === 'string' && info.name diff --git a/karpeslop-bin.js b/karpeslop-bin.js index f4fb675..fff2a24 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -1043,6 +1043,7 @@ class AISlopDetector { // registry-installed dependencies, so they should not be checked // for package freshness. if (!isRegistryBackedLockfileEntry(pkgPath)) continue; + if (pkgInfo === null || pkgInfo === undefined) continue; const info = pkgInfo; const version = info.version; const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; From b1f1379281730b8f6c7293f10b185f2d94fa60c6 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 12:37:48 -0400 Subject: [PATCH 35/38] fix: escape glob-special characters in isIgnoredByConfig to prevent literal paths from being misinterpreted --- ai-slop-detector.ts | 13 ++++++++----- karpeslop-bin.js | 12 +++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index e0dd05b..da48f40 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -14,6 +14,9 @@ import path from 'path'; import { glob } from 'glob'; import { fileURLToPath } from 'url'; +const globSync = glob.sync; +const globEscape = glob.escape; + interface SlopScoreBreakdown { informationUtility: number; informationQuality: number; @@ -615,7 +618,7 @@ class AISlopDetector { return false; } - return glob.sync(relativePath, { + return globSync(globEscape(relativePath), { cwd: this.rootDir, ignore: this.getGlobIgnorePatterns() }).length === 0; @@ -629,7 +632,7 @@ class AISlopDetector { for (const ext of this.targetExtensions) { const pattern = path.join(this.rootDir, `**/*${ext}`).replace(/\\/g, '/'); - const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); + const files = globSync(pattern, { ignore: this.getGlobIgnorePatterns() }); const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...filteredFiles); } @@ -643,7 +646,7 @@ class AISlopDetector { ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir) ? [rootManifestPath] : []), - ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) + ...globSync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) ].filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...manifestFiles); } @@ -683,7 +686,7 @@ class AISlopDetector { } else if (stat.isDirectory()) { for (const ext of this.targetExtensions) { const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); - const files = glob.sync(pattern, { ignore: this.getGlobIgnorePatterns() }); + const files = globSync(pattern, { ignore: this.getGlobIgnorePatterns() }); const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...filteredFiles); } @@ -696,7 +699,7 @@ class AISlopDetector { ...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir, true) ? [rootManifestPath] : []), - ...glob.sync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) + ...globSync(nestedPattern, { ignore: this.getGlobIgnorePatterns() }) ].filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...manifestFiles); } diff --git a/karpeslop-bin.js b/karpeslop-bin.js index fff2a24..257cf71 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -12,6 +12,8 @@ import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import { fileURLToPath } from 'url'; +const globSync = glob.sync; +const globEscape = glob.escape; // Phase 6: Configuration file support @@ -475,7 +477,7 @@ class AISlopDetector { if (relativePath.startsWith('..')) { return false; } - return glob.sync(relativePath, { + return globSync(globEscape(relativePath), { cwd: this.rootDir, ignore: this.getGlobIgnorePatterns() }).length === 0; @@ -488,7 +490,7 @@ class AISlopDetector { const allFiles = []; for (const ext of this.targetExtensions) { const pattern = path.join(this.rootDir, `**/*${ext}`).replace(/\\/g, '/'); - const files = glob.sync(pattern, { + const files = globSync(pattern, { ignore: this.getGlobIgnorePatterns() }); const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir)); @@ -500,7 +502,7 @@ class AISlopDetector { for (const name of this.manifestFilenames) { const rootManifestPath = path.join(this.rootDir, name); const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir) ? [rootManifestPath] : []), ...globSync(nestedPattern, { ignore: this.getGlobIgnorePatterns() })].filter(file => !isExcludedPath(file, this.rootDir)); allFiles.push(...manifestFiles); @@ -538,7 +540,7 @@ class AISlopDetector { } else if (stat.isDirectory()) { for (const ext of this.targetExtensions) { const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); - const files = glob.sync(pattern, { + const files = globSync(pattern, { ignore: this.getGlobIgnorePatterns() }); const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir, true)); @@ -549,7 +551,7 @@ class AISlopDetector { // fresh_package_version rule still fires in monorepos/workspaces. const rootManifestPath = path.join(targetPath, manifestName); const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir, true) ? [rootManifestPath] : []), ...glob.sync(nestedPattern, { + const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir, true) ? [rootManifestPath] : []), ...globSync(nestedPattern, { ignore: this.getGlobIgnorePatterns() })].filter(file => !isExcludedPath(file, this.rootDir, true)); resolved.push(...manifestFiles); From 272a41b4450b3430dbaf733dcb20c0552dbbc026 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 12:56:21 -0400 Subject: [PATCH 36/38] fix: remove dead branch in generateReport (issues.length === 0 after early return) --- ai-slop-detector.ts | 5 +---- karpeslop-bin.js | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index da48f40..a75dda9 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1533,10 +1533,7 @@ class AISlopDetector { console.log(`Style / Taste (Soul) : ${score.style} pts`); console.log(`TOTAL KARPE-SLOP SCORE : ${score.total} pts`); - if (this.issues.length === 0) { - console.log(`\nCLEAN. Even Andrej would approve.`); - console.log(` "This codebase has taste." β€” @karpathy, probably`); - } else if (hasOnlyFreshPackageWarnings(this.issues)) { + if (hasOnlyFreshPackageWarnings(this.issues)) { console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); } else if (score.total > 50) { console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); diff --git a/karpeslop-bin.js b/karpeslop-bin.js index 257cf71..2386e08 100755 --- a/karpeslop-bin.js +++ b/karpeslop-bin.js @@ -1340,10 +1340,7 @@ class AISlopDetector { console.log(`Information Quality (Lies) : ${score.informationQuality} pts`); console.log(`Style / Taste (Soul) : ${score.style} pts`); console.log(`TOTAL KARPE-SLOP SCORE : ${score.total} pts`); - if (this.issues.length === 0) { - console.log(`\nCLEAN. Even Andrej would approve.`); - console.log(` "This codebase has taste." β€” @karpathy, probably`); - } else if (hasOnlyFreshPackageWarnings(this.issues)) { + if (hasOnlyFreshPackageWarnings(this.issues)) { console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); } else if (score.total > 50) { console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); From 109d468be64776a734d8985f3ab4eb3977a43ade Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 13:01:43 -0400 Subject: [PATCH 37/38] chore: remove karpeslop-bin.js and karpeslop.js from package and git tracking - Remove karpeslop-bin.js (68KB babel artifact) from npm files array; it's no longer the entry point since bin points to karpeslop-cli.js - Remove karpeslop.js (duplicate wrapper without signal handling); karpeslop-cli.js is the canonical entry point - Add karpeslop-bin.js to .gitignore; still generated by build script but no longer tracked or published - Remove karpeslop.js from tsconfig exclude (file deleted) --- .gitignore | 1 + karpeslop-bin.js | 1627 ---------------------------------------------- karpeslop.js | 45 -- package.json | 2 - tsconfig.json | 3 +- 5 files changed, 2 insertions(+), 1676 deletions(-) delete mode 100755 karpeslop-bin.js delete mode 100644 karpeslop.js diff --git a/.gitignore b/.gitignore index e85245e..a7c7ad6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +karpeslop-bin.js .npmignore .ai-slop-report.json .DS_Store diff --git a/karpeslop-bin.js b/karpeslop-bin.js deleted file mode 100755 index 2386e08..0000000 --- a/karpeslop-bin.js +++ /dev/null @@ -1,1627 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * AI Slop Detection Tool for Food Truck Finder Application - * - * This tool identifies common AI-generated code patterns that represent "AI Slop" - * including excessive use of `any` types, unsafe type assertions, and other problematic patterns. - * - * Follows industry best practices based on typescript-eslint patterns. - */ -import fs from 'fs'; -import path from 'path'; -import { glob } from 'glob'; -import { fileURLToPath } from 'url'; -const globSync = glob.sync; -const globEscape = glob.escape; - -// Phase 6: Configuration file support - -/** - * Parse a semver string into the concrete version and whether the original - * was a floating caret/tilde range. - * - * We intentionally only treat `^` and `~` as eligible for the package-age - * check because those are the common semver ranges that drift on install. - * Broader operators like `>=`, `1.x`, or `latest` are not resolved here. - */ -export function parseVersionRange(version) { - if (version.startsWith('^') || version.startsWith('~')) { - return { - actualVersion: version.slice(1), - isRange: true - }; - } - return { - actualVersion: version, - isRange: false - }; -} - -/** - * Run an array of async tasks with bounded concurrency. Used to throttle - * outbound HTTP calls to the npm registry (which rate-limits unauthenticated - * callers at ~600 req/min). - */ -async function pLimit(tasks, concurrency, shouldStop) { - const results = new Array(tasks.length); - let next = 0; - const workers = Array.from({ - length: Math.min(concurrency, tasks.length) - }, async () => { - while (true) { - if (shouldStop?.()) return; - const i = next++; - if (i >= tasks.length) return; - results[i] = await tasks[i](); - } - }); - await Promise.all(workers); - return results; -} -export function shouldAnalyzePathInQuietMode(filePath, rootDir, coreAppDirs) { - const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); - const base = path.basename(filePath); - if (base === 'package.json' || base === 'package-lock.json') { - return true; - } - return coreAppDirs.some(dir => relativePath.startsWith(dir)); -} -export function isRegistryBackedLockfileEntry(pkgPath) { - return pkgPath.includes('node_modules/'); -} -export function hasOnlyFreshPackageWarnings(issues) { - return issues.length > 0 && issues.every(issue => issue.type === 'fresh_package_version'); -} - -/** - * Determine whether a file path should be excluded from analysis. - * - * Segment-based matching means any file under e.g. `src/dist/` is still excluded - * because `dist` exists as a segment. Matching `dist` everywhere is the simpler, - * reliable choice for a build-artifact filter. - */ -export function isExcludedPath(filePath, rootDir, allowOutsideRoot = false) { - const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); - const isOutsideRoot = relativePath.startsWith('..'); - const pathToMatch = allowOutsideRoot && isOutsideRoot ? path.resolve(filePath).replace(/\\/g, '/') : relativePath; - const segments = pathToMatch.split('/'); - const excludedSegment = name => segments.includes(name); - return excludedSegment('generated') || excludedSegment('coverage') || excludedSegment('.next') || excludedSegment('node_modules') || excludedSegment('dist') || excludedSegment('build') || excludedSegment('.git') || excludedSegment('out') || excludedSegment('temp') || excludedSegment('types') || segments.some(segment => segment.startsWith('.')) || pathToMatch.endsWith('.d.ts') || !isOutsideRoot && (pathToMatch.endsWith('ai-slop-detector.ts') || pathToMatch.endsWith('improved-ai-slop-detector.ts')); -} -class AISlopDetector { - issues = []; - targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; - manifestFilenames = ['package.json', 'package-lock.json']; - - // Core application directories to prioritize in reporting - coreAppDirs = ['app/', 'components/', 'lib/', 'hooks/', 'services/']; - detectionPatterns = [ - // ==================== AXIS 1: INFORMATION UTILITY (Noise) ==================== - { - id: 'redundant_self_explanatory_comment', - pattern: /const\s+(\w+)\s*=\s*\1\s*;?\s*\/\/.?(?:set|assign|store)\s+\1\b/gi, - message: "Redundant comment explaining variable assignment to itself β€” peak AI slop", - severity: 'high', - description: 'e.g., const count = count; // assign count to count' - }, { - id: 'excessive_boilerplate_comment', - pattern: /\/\/\s*This (?:function|component|hook|variable|method).* (?:does|is|handles?|returns?|takes?|processes?)/gi, - message: "Boilerplate comment that restates the obvious β€” adds zero insight", - severity: 'medium', - description: 'AI-generated comments that explain the obvious' - }, { - id: 'debug_log_with_comment', - pattern: /console\.(log|debug|info)\([^)]+\)\s*;\s*\/\/\s*(?:debug|temp|test|check|log|print)/gi, - message: "Debug log with apologetic comment β€” AI trying to justify its existence", - severity: 'medium', - description: 'Debugging code that should not be in production', - skipTests: true - }, - // ==================== AXIS 2: INFORMATION QUALITY (Hallucinations) ==================== - { - id: 'hallucinated_react_import', - pattern: /import\s*{\s*(useRouter|useParams|useSearchParams|Link|Image|Script)\s*}\s*from\s*['"]react['"]/gi, - message: "Hallucinated React import β€” these do NOT exist in 'react'", - severity: 'critical', - description: 'React-specific APIs are NOT in the react package', - fix: "Import from correct package: 'next/router', 'next/link', 'next/image', 'next/script'", - learnMore: 'https://nextjs.org/docs/api-reference/next/router' - }, { - id: 'hallucinated_next_import', - pattern: /import\s*{\s*(getServerSideProps|getStaticProps|getStaticPaths)\s*}\s*from\s*['"]react['"]/gi, - message: "Next.js API imported from 'react' β€” 100% AI hallucination", - severity: 'critical', - description: 'Next.js APIs are NOT in the react package', - fix: "These are page-level exports, not imports. Export them from your page file directly.", - learnMore: 'https://nextjs.org/docs/basic-features/data-fetching' - }, { - id: 'todo_implementation_placeholder', - pattern: /\/\/\s*(?:TODO|FIXME|HACK).*(?:implement|add|finish|complete|your code|logic|here)/gi, - message: "AI gave up and wrote a TODO instead of thinking", - severity: 'high', - description: 'Placeholder comments where AI failed to implement', - fix: "Actually implement the logic, or if blocked, document WHY and create a tracking issue", - learnMore: 'https://refactoring.guru/smells/comments' - }, { - id: 'assumption_comment', - pattern: /\b(assuming|assumes?|presumably|apparently|it seems|seems like)\b.{0,50}\b(that|this|the|it)\b/gi, - message: "AI making unverified assumptions β€” dangerous in production", - severity: 'high', - description: 'Comments indicating unverified assumptions' - }, - // ==================== AXIS 3: STYLE / TASTE (The Vibe Check) ==================== - { - id: 'overconfident_comment', - pattern: /\/\/.*\b(obviously|clearly|simply|just|easy|trivial|basically|literally|of course|naturally|certainly|surely)\b/gi, - message: "Overconfident comment β€” AI pretending it understands when it doesn't", - severity: 'high', - description: 'Overconfident language indicating false certainty' - }, { - id: 'hedging_uncertainty_comment', - pattern: /\/\/.*\b(should work|hopefully|probably|might work|try this|i think|seems to|attempting to|looks like|appears to)\b/gi, - message: "AI hedging its bets β€” classic sign of low-confidence generation", - severity: 'high', - description: 'Uncertain language masked as implementation' - }, { - id: 'unnecessary_iife_wrapper', - pattern: /\bconst\s+\w+\s*=\s*\(\s*async\s*\(\)\s*=>\s*\{[\s\S]*?\}\)\(\)/g, - message: "Unnecessary IIFE wrapper β€” AI over-engineering a simple async call", - severity: 'medium', - description: 'Unnecessarily complex function wrapping' - }, { - id: 'vibe_coded_ternary_abuse', - pattern: /\?\s*['"][^'"]+['"]\s*:\s*['"][^'"]+['"]\s*\?\s*['"][^'"]+['"]\s*:\s*['"][^'"]+['"]/g, - message: "Nested ternary hell β€” AI trying to look clever", - severity: 'medium', - description: 'Overly complex nested ternary operations', - fix: "Extract to a switch statement or a lookup object for better readability" - }, { - id: 'magic_css_value', - pattern: /(\d{3,4}px|#[0-9a-fA-F]{3,8}\b|rgba?\([^)]+\)|hsl\(\d+)/g, - message: "Magic CSS value β€” extract to design token or const", - severity: 'low', - description: 'Hardcoded CSS values that should be constants', - fix: "Move to CSS variables, theme tokens, or a constants file" - }, - // ==================== PHASE 5: REACT-SPECIFIC ANTI-PATTERNS ==================== - { - id: 'useEffect_derived_state', - pattern: /useEffect\s*\(\s*\(\s*\)\s*=>\s*\{[^}]*set[A-Z]\w*\([^)]*\)/g, - message: "useEffect setting state from props/other state β€” consider useMemo or compute in render", - severity: 'high', - description: 'Using useEffect to derive state is often unnecessary', - fix: "If state depends only on props/other state, compute directly or use useMemo instead", - learnMore: 'https://react.dev/learn/you-might-not-need-an-effect' - }, { - id: 'useEffect_empty_deps_suspicious', - pattern: /useEffect\s*\([^,]+,\s*\[\s*\]\s*\)/g, - message: "useEffect with empty deps β€” verify this truly should only run on mount", - severity: 'medium', - description: 'Empty dependency arrays are often a sign of missing dependencies', - fix: "Review if effect depends on any props/state. Use eslint-plugin-react-hooks to catch issues.", - learnMore: 'https://react.dev/reference/react/useEffect#specifying-reactive-dependencies' - }, { - id: 'setState_in_loop', - pattern: /(?:for|while|forEach|map)\s*\([^)]+\)[^{]*\{[^}]*set[A-Z]\w*\(/g, - message: "setState inside a loop β€” may cause multiple re-renders", - severity: 'high', - description: 'Calling setState in a loop triggers multiple re-renders', - fix: "Batch updates by computing the final state outside the loop, then call setState once", - learnMore: 'https://react.dev/learn/queueing-a-series-of-state-updates' - }, { - id: 'useCallback_no_deps', - pattern: /useCallback\s*\([^,]+,\s*\[\s*\]\s*\)/g, - message: "useCallback with empty deps β€” the callback never updates", - severity: 'medium', - description: 'Empty deps means the callback is stale and may use outdated values', - fix: "Add all values used inside the callback to the dependency array", - learnMore: 'https://react.dev/reference/react/useCallback' - }, - // ==================== ORIGINAL PATTERNS ==================== - { - id: 'any_type_usage', - pattern: /:\s*any\b/g, - message: "Found 'any' type usage. Replace with specific type or unknown.", - severity: 'high', - description: 'Detects : any type annotations', - fix: "Replace with 'unknown' and use type guards to narrow, or define a proper interface", - learnMore: 'https://www.typescriptlang.org/docs/handbook/2/narrowing.html' - }, { - id: 'array_any_type', - pattern: /Array\s*<\s*any\s*>/g, - message: "Found Array type usage. Replace with specific type or unknown[].", - severity: 'high', - description: 'Detects Array patterns' - }, { - id: 'generic_any_type', - pattern: /<\s*any\s*>/g, - message: "Found generic type usage. Replace with specific type or unknown.", - severity: 'high', - description: 'Detects generic type parameters with any' - }, { - id: 'function_param_any_type', - pattern: /\(\s*.*\s*:\s*any\s*\)/g, - message: "Found function parameter with 'any' type. Replace with specific type or unknown.", - severity: 'high', - description: 'Detects function parameters with any type' - }, { - id: 'unsafe_type_assertion', - pattern: /\s+as\s+any\b/g, - message: "Found unsafe 'as any' type assertion. Use proper type guards or validation.", - severity: 'high', - description: 'Detects unsafe as any assertions', - fix: "Use 'as unknown as TargetType' or implement a runtime type guard with validation", - learnMore: 'https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates' - }, { - id: 'unsafe_double_type_assertion', - pattern: /as\s+\w+\s+as\s+\w+/g, - message: "Found unsafe double type assertion. Consider using 'as unknown as Type' for safe conversions.", - severity: 'high', - description: 'Detects unsafe double type assertions' - }, { - id: 'index_signature_any', - pattern: /\[\s*["'`]?(\w+)["'`]?[^\]]*\]\s*:\s*any/g, - message: "Found index signature with 'any' type. Replace with specific type or unknown.", - severity: 'high', - description: 'Detects index signatures with any type' - }, { - id: 'missing_error_handling', - pattern: /\b(fetch|axios|http)\s*\(/g, - message: "Potential missing error handling for promise. Consider adding try/catch or .catch().", - severity: 'medium', - description: 'Detects calls that might need error handling', - fix: "Wrap in try/catch or add .catch() handler. Consider React Query or SWR for data fetching.", - learnMore: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try...catch', - skipTests: true - }, { - id: 'production_console_log', - pattern: /console\.(log|warn|error|info|debug|trace)\(/g, - message: "Found console logging in production code. Remove before deployment.", - severity: 'medium', - description: 'Detects console logs in production code', - skipTests: true, - skipMocks: true - }, { - id: 'todo_comment', - pattern: /\b(TODO|FIXME|HACK|XXX|BUG)\b/g, - message: "Found TODO/FIXME/HACK comment indicating incomplete implementation.", - severity: 'medium', - description: 'Detects incomplete implementation markers' - }, - // Note: complex_nested_conditionals is handled separately below with improved logic - { - id: 'unsafe_member_access', - pattern: /\.\s*any\s*\[/g, - message: "Found potentially unsafe member access on 'any' type.", - severity: 'high', - description: 'Detects unsafe member access patterns' - }]; - config = {}; - customIgnorePaths = []; - npmPackageCache = new Map(); - registryWarningLogged = false; - registryUnavailable = false; - reportedFreshPackageKeys = new Set(); - targetPaths = []; - constructor(rootDir, targetPaths) { - this.rootDir = rootDir; - this.loadConfig(); - if (targetPaths && targetPaths.length > 0) { - this.targetPaths = targetPaths.map(p => path.isAbsolute(p) ? p : path.resolve(this.rootDir, p)); - } - } - - /** - * Validate configuration structure (Issue 3 fix) - * Basic validation without external dependencies - */ - validateConfig(config) { - if (typeof config !== 'object' || config === null) { - throw new Error('Config must be an object'); - } - const validSeverities = ['critical', 'high', 'medium', 'low']; - const cfg = config; - - // Validate customPatterns - if (cfg.customPatterns !== undefined) { - if (!Array.isArray(cfg.customPatterns)) { - throw new Error('customPatterns must be an array'); - } - for (let i = 0; i < cfg.customPatterns.length; i++) { - const pattern = cfg.customPatterns[i]; - if (!pattern.id || typeof pattern.id !== 'string') { - throw new Error(`customPatterns[${i}].id must be a string`); - } - if (!pattern.pattern || typeof pattern.pattern !== 'string') { - throw new Error(`customPatterns[${i}].pattern must be a string`); - } - if (!pattern.message || typeof pattern.message !== 'string') { - throw new Error(`customPatterns[${i}].message must be a string`); - } - if (!pattern.severity || !validSeverities.includes(pattern.severity)) { - throw new Error(`customPatterns[${i}].severity must be one of: ${validSeverities.join(', ')}`); - } - // Validate regex is valid - try { - new RegExp(pattern.pattern, 'gi'); - } catch (e) { - throw new Error(`customPatterns[${i}].pattern is not a valid regex: ${pattern.pattern}`); - } - } - } - - // Validate severityOverrides - if (cfg.severityOverrides !== undefined) { - if (typeof cfg.severityOverrides !== 'object' || cfg.severityOverrides === null) { - throw new Error('severityOverrides must be an object'); - } - for (const [key, value] of Object.entries(cfg.severityOverrides)) { - if (!validSeverities.includes(value)) { - throw new Error(`severityOverrides.${key} must be one of: ${validSeverities.join(', ')}`); - } - } - } - - // Validate ignorePaths - if (cfg.ignorePaths !== undefined) { - if (!Array.isArray(cfg.ignorePaths)) { - throw new Error('ignorePaths must be an array of strings'); - } - for (let i = 0; i < cfg.ignorePaths.length; i++) { - if (typeof cfg.ignorePaths[i] !== 'string') { - throw new Error(`ignorePaths[${i}] must be a string`); - } - } - } - - // Validate minPackageAgeDays - if (cfg.minPackageAgeDays !== undefined) { - const v = cfg.minPackageAgeDays; - if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) { - throw new Error('minPackageAgeDays must be a finite non-negative number'); - } - } - return cfg; - } - - /** - * Load configuration from .karpesloprc.json if it exists - */ - loadConfig() { - const configPaths = [path.join(this.rootDir, '.karpesloprc.json'), path.join(this.rootDir, '.karpesloprc'), path.join(this.rootDir, 'karpeslop.config.json')]; - for (const configPath of configPaths) { - if (fs.existsSync(configPath)) { - try { - const configContent = fs.readFileSync(configPath, 'utf-8'); - const rawConfig = JSON.parse(configContent); - - // Issue 3: Validate config before using - this.config = this.validateConfig(rawConfig); - console.log(`πŸ“‹ Loaded config from ${path.basename(configPath)}\n`); - - // Add custom patterns - if (this.config.customPatterns) { - for (const customPattern of this.config.customPatterns) { - this.detectionPatterns.push({ - id: customPattern.id, - pattern: new RegExp(customPattern.pattern, 'gi'), - message: customPattern.message, - severity: customPattern.severity, - description: customPattern.description || customPattern.message, - fix: customPattern.fix, - learnMore: customPattern.learnMore - }); - } - console.log(` Added ${this.config.customPatterns.length} custom pattern(s)`); - } - - // Apply severity overrides - if (this.config.severityOverrides) { - for (const [patternId, newSeverity] of Object.entries(this.config.severityOverrides)) { - const pattern = this.detectionPatterns.find(p => p.id === patternId); - if (pattern) { - pattern.severity = newSeverity; - } - } - } - - // Store ignore paths - if (this.config.ignorePaths) { - this.customIgnorePaths = this.config.ignorePaths; - } - break; // Stop after finding first valid config - } catch (error) { - console.warn(`⚠️ Failed to parse config at ${configPath}:`, error); - } - } - } - } - - /** - * Run the AI Slop detection across the codebase - */ - async detect(quiet = false) { - console.log('πŸ” Starting AI Slop detection...\n'); - let filesToAnalyze; - if (this.targetPaths.length > 0) { - const resolvedTargetFiles = this.resolveTargetPaths(); - if (resolvedTargetFiles.length === 0) { - throw new Error('No valid target files found for the supplied paths'); - } - filesToAnalyze = quiet ? resolvedTargetFiles.filter(file => this.shouldAnalyzeInQuietMode(file)) : resolvedTargetFiles; - console.log(`🎯 Targeting ${filesToAnalyze.length} file(s) (explicit paths)\n`); - } else { - const allFiles = this.findAllFiles(); - filesToAnalyze = quiet ? allFiles.filter(file => this.shouldAnalyzeInQuietMode(file)) : allFiles; - console.log(`πŸ“ Found ${allFiles.length} files to analyze (${filesToAnalyze.length} in ${quiet ? 'quiet' : 'full'} mode)\n`); - } - - // 2. Analyze each file for AI Slop patterns - for (const file of filesToAnalyze) { - await this.analyzeFile(file, quiet); - } - - // 3. Report findings - this.generateReport(quiet); - return this.issues; - } - getGlobIgnorePatterns() { - return ['node_modules/**', '.next/**', 'dist/**', 'build/**', 'coverage/**', 'generated/**', '.vercel/**', '.git/**', '**/types/**', '**/node_modules/**', '**/.*', '**/*.d.ts', '**/coverage/**', '**/out/**', '**/temp/**', 'scripts/ai-slop-detector.ts', 'ai-slop-detector.ts', 'improved-ai-slop-detector.ts', ...this.customIgnorePaths]; - } - shouldAnalyzeInQuietMode(filePath) { - return shouldAnalyzePathInQuietMode(filePath, this.rootDir, this.coreAppDirs); - } - isIgnoredByConfig(filePath) { - const relativePath = path.relative(this.rootDir, filePath).replace(/\\/g, '/'); - if (relativePath.startsWith('..')) { - return false; - } - return globSync(globEscape(relativePath), { - cwd: this.rootDir, - ignore: this.getGlobIgnorePatterns() - }).length === 0; - } - - /** - * Find all TypeScript/JavaScript files in the project, plus manifest files - */ - findAllFiles() { - const allFiles = []; - for (const ext of this.targetExtensions) { - const pattern = path.join(this.rootDir, `**/*${ext}`).replace(/\\/g, '/'); - const files = globSync(pattern, { - ignore: this.getGlobIgnorePatterns() - }); - const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir)); - allFiles.push(...filteredFiles); - } - - // Also pick up manifest files at the project root and below it for - // package-age analysis in monorepos/workspaces. - for (const name of this.manifestFilenames) { - const rootManifestPath = path.join(this.rootDir, name); - const nestedPattern = path.join(this.rootDir, '**', name).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir) ? [rootManifestPath] : []), ...globSync(nestedPattern, { - ignore: this.getGlobIgnorePatterns() - })].filter(file => !isExcludedPath(file, this.rootDir)); - allFiles.push(...manifestFiles); - } - - // Remove duplicates and return - return [...new Set(allFiles)]; - } - - /** - * Resolve target paths (files and directories) passed via CLI - * Expands directories into their scannable files, validates files exist and have correct extensions - */ - resolveTargetPaths() { - const resolved = []; - for (const targetPath of this.targetPaths) { - if (!fs.existsSync(targetPath)) { - console.warn(`⚠️ Target path does not exist, skipping: ${targetPath}`); - continue; - } - const stat = fs.statSync(targetPath); - if (stat.isFile()) { - const ext = path.extname(targetPath); - const base = path.basename(targetPath); - const isManifest = ext === '.json' && this.manifestFilenames.includes(base); - if (this.isIgnoredByConfig(targetPath)) { - console.warn(`⚠️ Target file matches ignore paths, skipping: ${targetPath}`); - } else if (isExcludedPath(targetPath, this.rootDir, true)) { - console.warn(`⚠️ Target file is in an excluded path, skipping: ${targetPath}`); - } else if (this.targetExtensions.includes(ext) || isManifest) { - resolved.push(targetPath); - } else { - console.warn(`⚠️ Target file has unsupported extension (${ext}), skipping: ${targetPath}`); - } - } else if (stat.isDirectory()) { - for (const ext of this.targetExtensions) { - const pattern = path.join(targetPath, `**/*${ext}`).replace(/\\/g, '/'); - const files = globSync(pattern, { - ignore: this.getGlobIgnorePatterns() - }); - const filteredFiles = files.filter(file => !isExcludedPath(file, this.rootDir, true)); - resolved.push(...filteredFiles); - } - for (const manifestName of this.manifestFilenames) { - // Pick up manifests both at the directory root and below it so the - // fresh_package_version rule still fires in monorepos/workspaces. - const rootManifestPath = path.join(targetPath, manifestName); - const nestedPattern = path.join(targetPath, '**', manifestName).replace(/\\/g, '/'); - const manifestFiles = [...(fs.existsSync(rootManifestPath) && !this.isIgnoredByConfig(rootManifestPath) && !isExcludedPath(rootManifestPath, this.rootDir, true) ? [rootManifestPath] : []), ...globSync(nestedPattern, { - ignore: this.getGlobIgnorePatterns() - })].filter(file => !isExcludedPath(file, this.rootDir, true)); - resolved.push(...manifestFiles); - } - } - } - return [...new Set(resolved)]; - } - - /** - * Check if a fetch call is properly handled with try/catch or .catch() - */ - isFetchCallProperlyHandled(lines, fetchLineIndex) { - // Look in a reasonable range around the fetch call to see if it's in a try/catch block - // or has a .catch() or similar error handling - - // First, find the function context containing this fetch call - let functionStart = -1; - let functionEnd = -1; - - // Look backwards to find the start of the function - for (let i = fetchLineIndex; i >= Math.max(0, fetchLineIndex - 20); i--) { - const line = lines[i]; - const isReactHook = line.includes('const') && (line.includes('useState') || line.includes('useEffect') || line.includes('useCallback') || line.includes('useMemo')); - if (line.includes('async function') || line.includes('function') || line.includes('=>') || isReactHook || line.includes('export default function')) { - // Check if this looks like the start of our function - if (line.includes('{') || line.includes('=>')) { - functionStart = i; - break; - } - } - // Check for arrow functions in the line above - if (i > 0 && (lines[i - 1] + line).includes('=>')) { - // Look for functions that end with an opening brace - if (line.trim().startsWith('{')) { - functionStart = i; - break; - } - } - } - - // Look forwards to find the end of the function block - let braceCount = 0; - let inFunction = false; - for (let i = functionStart === -1 ? 0 : functionStart; i < lines.length && i < fetchLineIndex + 20; i++) { - const line = lines[i]; - for (let j = 0; j < line.length; j++) { - if (line[j] === '{') { - braceCount++; - if (i === functionStart && braceCount === 1) { - inFunction = true; - } - } else if (line[j] === '}') { - braceCount--; - if (inFunction && braceCount === 0) { - functionEnd = i; - break; - } - } - } - if (functionEnd !== -1) break; - } - if (functionStart === -1 || functionEnd === -1) { - // If we can't find function boundaries, check the current line and nearby lines for error handling - // Check current line and 2 lines before and after - const start = Math.max(0, fetchLineIndex - 2); - const end = Math.min(lines.length, fetchLineIndex + 3); - for (let i = start; i < end; i++) { - const line = lines[i]; - if (line.includes('.catch(') || line.includes('try {') || line.includes('try{') || i > 0 && lines[i - 1].includes('try') && line.includes('.catch(')) { - return true; - } - } - return false; - } - - // Now check the entire function for try/catch or .catch - for (let i = functionStart; i <= functionEnd; i++) { - const line = lines[i]; - if (line.includes('.catch(') || line.includes('try {') || line.includes('try{')) { - return true; - } - } - - // Check if the fetch call is part of a promise chain that ends with .catch - const currentLine = lines[fetchLineIndex]; - if (currentLine.includes('fetch(') && (currentLine.includes('.then(') || currentLine.includes('.catch('))) { - // Look for .catch in the same or following lines within the same statement - for (let i = fetchLineIndex; i < Math.min(lines.length, fetchLineIndex + 5); i++) { - const line = lines[i]; - if (line.includes('.catch(')) { - return true; - } - // If we find another statement (not a continuation), stop looking - if (line.includes(';') && !line.trim().endsWith('\\') && !line.trim().endsWith(',')) { - break; - } - } - } - return false; - } - - /** - * Analyze a single file for AI Slop patterns - */ - async analyzeFile(filePath, quiet = false) { - const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - - // Check if this is a test or mock file - const isTestFile = filePath.includes('__tests__') || filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__mocks__') || filePath.includes('test-'); - const isMockFile = filePath.includes('__mocks__') || filePath.includes('mock'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineNumber = i + 1; - - // Apply each detection pattern - for (const pattern of this.detectionPatterns) { - // Skip certain patterns in test/mock files - if (pattern.skipTests && isTestFile || pattern.skipMocks && isMockFile) { - continue; - } - - // Skip the complex_nested_conditionals pattern since we handle it separately - if (pattern.id === 'complex_nested_conditionals') { - continue; - } - - // Create a new RegExp object for each check to reset lastIndex - const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags); - let match; - while ((match = regex.exec(line)) !== null) { - // ========== PHASE 1: CONTEXT-AWARE WHITELISTING ========== - - // Skip any pattern that has an explicit eslint-disable or ts-expect-error on the same or previous line - if (pattern.id.includes('any') || pattern.id.includes('unsafe')) { - const prevLine = i > 0 ? lines[i - 1] : ''; - if (line.includes('eslint-disable') || line.includes('@ts-expect-error') || line.includes('@ts-ignore') || prevLine.includes('eslint-disable-next-line') || prevLine.includes('@ts-expect-error')) { - continue; // Developer explicitly acknowledged this - } - } - - // Skip .d.ts declaration files entirely for 'any' related patterns - if (pattern.id.includes('any') && filePath.endsWith('.d.ts')) { - continue; // Declaration files often need 'any' for external library types - } - - // Skip legitimate cases like expect.any() in tests - if (pattern.id === 'any_type_usage' && (line.includes('expect.any(') || line.includes('jest.fn()'))) { - continue; - } - - // Skip JSX spread attributes which often legitimately use 'any' - if (pattern.id === 'any_type_usage' && line.includes('{...') && line.includes('as any')) { - continue; - } - - // Skip legitimate JSON parsing patterns - if (pattern.id === 'any_type_usage' && (line.includes('JSON.parse(') || line.includes('.json') || line.includes('response.json'))) { - continue; - } - - // Skip legitimate API response handling where 'any' is often unavoidable - if (pattern.id === 'any_type_usage' && (line.includes('ApiResponse') || line.includes('apiResponse') || line.includes('res.json') || line.includes('fetch') || line.includes('axios'))) { - continue; - } - - // Skip legitimate uses of 'any' for dynamic data processing - if (pattern.id === 'any_type_usage' && (line.includes('data: any') || line.includes('(data: any)') || line.includes('result: any') || line.includes('response: any'))) { - // Check if it's in a function that processes dynamic data - if (line.includes('parse') || line.includes('process') || line.includes('transform')) { - continue; - } - } - - // Special handling for function_param_any_type pattern - if (pattern.id === 'function_param_any_type') { - // Skip legitimate uses in data processing functions - if (line.includes('(data: any)') && (line.includes('parse') || line.includes('process') || line.includes('transform'))) { - continue; - } - - // Skip legitimate uses in generic functions dealing with external data - if (line.includes('ApiResponse') || line.includes('apiResponse') || line.includes('JSON.parse') || line.includes('response: any')) { - continue; - } - } - - // Special handling for missing error handling - look for properly handled fetch calls - if (pattern.id === 'missing_error_handling') { - const fullLine = line.trim(); - // Skip matches inside comment lines (single-line, JSDoc, block) - if (fullLine.startsWith('//') || fullLine.startsWith('*') || fullLine.startsWith('/*')) { - continue; - } - // Check if this fetch call is part of a properly handled async function - const isProperlyHandled = this.isFetchCallProperlyHandled(lines, i); - if (isProperlyHandled) { - continue; - } - } - - // Special handling for unsafe_double_type_assertion - skip legitimate UI library patterns - if (pattern.id === 'unsafe_double_type_assertion') { - const fullLine = line.trim(); - // Skip patterns that are actually safe (as unknown as Type) - if (fullLine.includes('as unknown as')) { - continue; - } - // Skip matches inside comment lines (e.g., "as soon as React") - if (fullLine.startsWith('//') || fullLine.startsWith('*') || fullLine.startsWith('/*')) { - continue; - } - // Skip matches where the first word after "as" is a common English word - // indicating natural language rather than a type assertion - // e.g., "as soon as React hydrates" β€” "soon" is English, not a type - const firstWord = match[0].match(/^as\s+(\w+)/i)?.[1]?.toLowerCase(); - const englishWords = ['soon', 'quick', 'quickly', 'fast', 'smooth', 'long', 'much', 'little', 'well', 'good', 'bad', 'easy', 'hard', 'simple', 'clear', 'many', 'few', 'close', 'far', 'near']; - if (firstWord && englishWords.includes(firstWord)) { - continue; - } - } - - // Special handling for production_console_log - skip legitimate error handling and debugging patterns - if (pattern.id === 'production_console_log') { - const fullLine = line.trim(); - - // Skip console.error logs inside catch blocks (legitimate error handling) - if (fullLine.includes('console.error(') && this.isInTryCatchBlock(lines, i)) { - continue; - } - - // Skip console calls guarded by a conditional on the same line - // e.g., if (isDev) console.log('debug'); - if (/^if\s*\(/.test(fullLine)) { - continue; - } - - // Skip console calls inside a conditional block opened on a prior line - if (i > 0) { - const prevLine = lines[i - 1].trim(); - if (/^if\s*\(/.test(prevLine) && !prevLine.includes('function') && (prevLine.includes('{') || fullLine.startsWith('{') === false)) { - continue; - } - } - - // Skip general debugging logs that might be intentional in development - if (fullLine.includes('console.log(') && (fullLine.includes('Debug') || fullLine.includes('debug') || fullLine.includes('debug:'))) { - continue; - } - - // Skip console logs that contain the word 'error' in a non-error context (like error handling) - if ((fullLine.includes('console.log(') || fullLine.includes('console.info(')) && (fullLine.includes('error') || fullLine.includes('Error'))) { - continue; - } - } - - // Special handling for hedging_uncertainty_comment - skip legitimate test patterns - if (pattern.id === 'hedging_uncertainty_comment' || pattern.id === 'assumption_comment') { - // Skip these patterns in test files where they might be legitimate test descriptions - if (filePath.includes('test') || filePath.includes('spec') || filePath.includes('__tests__')) { - continue; - } - - // Skip common English phrases that are not code-related - const fullLine = line.trim().toLowerCase(); - if (fullLine.includes('should work') && (fullLine.includes('//') || fullLine.includes('/*') || fullLine.includes('*/'))) { - // This is likely a comment in a test file - continue; - } - } - - // Special handling for unsafe_type_assertion - skip legitimate test patterns - if (pattern.id === 'unsafe_type_assertion') { - // Skip these in test files where they might be legitimate for testing - if (filePath.includes('test') || filePath.includes('spec') || filePath.includes('__tests__')) { - continue; - } - } - - // In quiet mode, skip test and mock files for all patterns except production console logs - if (quiet && pattern.id !== 'production_console_log') { - const isTestFile = filePath.includes('__tests__') || filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__mocks__') || filePath.includes('test-'); - if (isTestFile) { - continue; - } - } - this.issues.push({ - type: pattern.id, - file: filePath, - line: lineNumber, - column: match.index + 1, - code: match[0], - message: `${pattern.message} (${pattern.description})`, - severity: pattern.severity - }); - } - } - - // Now handle complex nested conditionals separately with improved logic - this.analyzeComplexNestedConditionals(filePath, lines, i, lineNumber, line); - } - - // Special handling for package.json and package-lock.json to detect fresh package versions - if (path.basename(filePath) === 'package.json' || path.basename(filePath) === 'package-lock.json') { - await this.analyzePackageVersions(filePath, content); - } - } - - /** - * Analyze complex nested conditionals using a more sophisticated approach - * This tracks nesting depth rather than just finding control structure keywords - */ - analyzeComplexNestedConditionals(filePath, lines, lineIndex, lineNumber, line) { - // Count opening braces in this line to determine if we're entering nested blocks - const ifMatches = line.match(/\bif\s*\(/g); - const forMatches = line.match(/\bfor\s*\(/g); - const whileMatches = line.match(/\bwhile\s*\(/g); - - // Only flag if there are potentially nested control structures in a single line - // or if the line has multiple indicators of complexity - if (ifMatches && ifMatches.length > 1 || forMatches && forMatches.length > 1 || whileMatches && whileMatches.length > 1 || ifMatches && (forMatches || whileMatches) || forMatches && whileMatches) { - this.issues.push({ - type: 'complex_nested_conditionals', - file: filePath, - line: lineNumber, - column: 1, - code: line.trim(), - message: "Found potentially complex nested control structures in a single line. Consider refactoring for readability.", - severity: 'medium' - }); - } - - // Also look for deeply nested if statements across multiple lines - // Count indentation to detect nesting - const indentation = line.search(/\S/); // Get leading whitespace length - if (indentation >= 16 && (line.includes('if (') || line.includes('for (') || line.includes('while ('))) { - // This might indicate a highly nested structure - // But first, verify it's not a simple case like formatting - const trimmedLine = line.trim(); - if (!trimmedLine.startsWith('//') && !trimmedLine.includes('=>')) { - // Skip comments and arrow functions - this.issues.push({ - type: 'complex_nested_conditionals', - file: filePath, - line: lineNumber, - column: indentation + 1, - code: line.trim(), - message: "Highly indented control structure suggests deep nesting. Consider refactoring for readability.", - severity: 'medium' - }); - } - } - } - - /** - * Check if a particular line is within a try-catch block - * Used to determine if console.error is legitimate error handling - */ - isInTryCatchBlock(lines, lineIndex) { - let braceDepth = 0; - let inCatchBlock = false; - let catchBlockDepth = -1; - let nestedDepth = 0; - let pendingExit = false; - for (let i = 0; i <= lineIndex; i++) { - const line = lines[i]; - const hasCatch = line.includes('catch (') || line.includes('catch('); - const catchOnSameLineAsCloseBrace = hasCatch && line.trim().startsWith('}'); - for (let j = 0; j < line.length; j++) { - if (line[j] === '{') { - if (inCatchBlock && !catchOnSameLineAsCloseBrace) { - if (braceDepth >= catchBlockDepth) { - nestedDepth++; - } - } - braceDepth++; - } else if (line[j] === '}') { - braceDepth--; - if (inCatchBlock) { - if (nestedDepth > 0) { - nestedDepth--; - if (nestedDepth === 0) { - pendingExit = true; - } - } else if (braceDepth <= catchBlockDepth) { - inCatchBlock = false; - nestedDepth = 0; - pendingExit = false; - } - } - } - } - if (pendingExit) { - pendingExit = false; - } - if (hasCatch) { - if (line.includes('{')) { - if (catchOnSameLineAsCloseBrace) { - const closeBraceIdx = line.indexOf('}'); - const catchIdx = line.indexOf('catch'); - const openBraceIdx = line.indexOf('{', catchIdx); - if (closeBraceIdx !== -1 && closeBraceIdx < catchIdx && openBraceIdx > catchIdx) { - braceDepth--; - } - for (let j = 0; j < openBraceIdx; j++) { - if (line[j] === '{') braceDepth++; - } - for (let j = openBraceIdx + 1; j < line.length; j++) { - if (line[j] === '{') nestedDepth++;else if (line[j] === '}') { - nestedDepth--; - if (nestedDepth === 0) { - pendingExit = true; - } - } - } - inCatchBlock = true; - catchBlockDepth = braceDepth; - nestedDepth = 0; - } else { - inCatchBlock = true; - catchBlockDepth = braceDepth - 1; - nestedDepth = 0; - pendingExit = false; - } - } else { - for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { - if (lines[j].includes('{')) { - inCatchBlock = true; - catchBlockDepth = braceDepth; - nestedDepth = 0; - pendingExit = false; - break; - } - } - } - } - } - return inCatchBlock; - } - - /** - * Analyze package.json or package-lock.json for fresh (unstable) package versions - * Flags packages updated less than minPackageAgeDays ago (default 7) - */ - async analyzePackageVersions(filePath, content) { - const minAgeDays = this.config.minPackageAgeDays ?? 7; - const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000; - const packageEntries = []; - const lockfileRoot = path.dirname(filePath); - const normalizePath = input => input.replace(/\\/g, '/'); - const lockfileScopeKey = pkgPath => { - const normalizedPkgPath = normalizePath(pkgPath); - const nodeModulesIndex = normalizedPkgPath.lastIndexOf('/node_modules/'); - const scopeRelative = nodeModulesIndex !== -1 ? normalizedPkgPath.slice(0, nodeModulesIndex) : normalizedPkgPath.startsWith('node_modules/') ? '' : normalizedPkgPath; - return normalizePath(path.resolve(lockfileRoot, scopeRelative || '.')); - }; - const collectLegacyLockfileEntries = (dependencies, trail = []) => { - if (typeof dependencies !== 'object' || dependencies === null) { - return; - } - for (const [depName, depInfo] of Object.entries(dependencies)) { - if (typeof depInfo !== 'object' || depInfo === null) { - continue; - } - const info = depInfo; - const version = typeof info.version === 'string' ? info.version : ''; - const pkgName = typeof info.name === 'string' && info.name ? info.name : depName; - const scopeKey = normalizePath(lockfileRoot); - const sourceId = ['dependencies', ...trail, depName].join('/'); - if (pkgName && version) { - packageEntries.push({ - scopeKey, - sourceId, - pkgName, - version - }); - } - if (info.dependencies && typeof info.dependencies === 'object') { - collectLegacyLockfileEntries(info.dependencies, [...trail, depName]); - } - } - }; - try { - const pkg = JSON.parse(content); - if (filePath.endsWith('package-lock.json')) { - if (pkg.packages) { - for (const [pkgPath, pkgInfo] of Object.entries(pkg.packages)) { - if (pkgPath === '' || pkgPath === 'node_modules/') continue; - // Workspace entries like `packages/ui` are local packages, not - // registry-installed dependencies, so they should not be checked - // for package freshness. - if (!isRegistryBackedLockfileEntry(pkgPath)) continue; - if (pkgInfo === null || pkgInfo === undefined) continue; - const info = pkgInfo; - const version = info.version; - const pkgName = typeof info.name === 'string' && info.name ? info.name : pkgPath.split('node_modules/').pop() || pkgPath; - if (pkgName && version) { - packageEntries.push({ - scopeKey: lockfileScopeKey(pkgPath), - sourceId: pkgPath, - pkgName, - version - }); - } - } - } else if (pkg.dependencies) { - collectLegacyLockfileEntries(pkg.dependencies); - } - } else if (typeof pkg === 'object' && pkg !== null) { - const packageJsonScopeKey = normalizePath(path.dirname(filePath)); - const p = pkg; - for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { - if (p[key]) { - for (const [pkgName, version] of Object.entries(p[key])) { - packageEntries.push({ - scopeKey: packageJsonScopeKey, - sourceId: key, - pkgName, - version - }); - } - } - } - } - } catch { - return; - } - const entries = packageEntries.map(({ - scopeKey, - sourceId, - pkgName, - version - }) => ({ - scopeKey, - sourceId, - pkgName, - ...parseVersionRange(version) - })) - // Keep the rule narrow on purpose: only caret/tilde ranges are treated - // as "fresh package" candidates in package.json. package-lock.json is - // always checked because it contains the resolved version. - .filter(({ - isRange - }) => isRange || filePath.endsWith('package-lock.json')); - - // Bound concurrency: npm registry rate-limits unauthenticated callers at - // ~600 req/min, and a typical package-lock.json has 200-1000 deps. - const NPM_CONCURRENCY = 5; - const ageInfos = await pLimit(entries.map(({ - scopeKey, - sourceId, - pkgName, - actualVersion - }) => () => this.getNpmPackageAge(pkgName, actualVersion).then(ageInfo => ({ - scopeKey, - sourceId, - pkgName, - version: actualVersion, - ageInfo - }))), NPM_CONCURRENCY, () => this.registryUnavailable); - for (const ageInfoResult of ageInfos) { - if (!ageInfoResult) { - continue; - } - const { - scopeKey, - sourceId, - pkgName, - version, - ageInfo - } = ageInfoResult; - if (ageInfo && ageInfo.ageMs < minAgeMs) { - const issueKey = `${scopeKey}|${pkgName}|${version}`; - if (this.reportedFreshPackageKeys.has(issueKey)) { - continue; - } - this.reportedFreshPackageKeys.add(issueKey); - const daysOld = Math.floor(ageInfo.ageMs / (24 * 60 * 60 * 1000)); - this.issues.push({ - type: 'fresh_package_version', - file: filePath, - line: 1, - column: 1, - code: filePath.endsWith('package-lock.json') ? `${sourceId}: ${pkgName}@${version}` : `"${pkgName}": "${version}"`, - message: filePath.endsWith('package-lock.json') ? `Package '${pkgName}' from ${sourceId} v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating` : `Package '${pkgName}' v${version} is only ${daysOld} day${daysOld === 1 ? '' : 's'} old β€” wait at least ${minAgeDays} days before updating`, - severity: 'medium' - }); - } - } - } - - /** - * Get npm package version age info with caching. - * Cache stores the full `time` map from the registry so repeated lookups - * for different versions of the same package don't re-fetch the package. - */ - async getNpmPackageAge(pkgName, version) { - const now = Date.now(); - const cached = this.npmPackageCache.get(pkgName); - if (cached) { - // Cached map contains every version the registry knows about. If our - // requested version isn't here, the version genuinely doesn't exist - // (or was unpublished) β€” don't refetch, that would just re-fetch the - // same map we already have. - const versionTime = cached[version]; - if (!versionTime) return null; - const publishTime = new Date(versionTime).getTime(); - return { - version, - time: versionTime, - ageMs: now - publishTime - }; - } - if (this.registryUnavailable) { - return null; - } - let data = null; - try { - const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5000); - let response; - try { - response = await fetch(url, { - signal: controller.signal - }); - } finally { - clearTimeout(timer); - } - if (!response.ok) return null; - data = await response.json(); - } catch { - if (!this.registryWarningLogged) { - console.warn('⚠️ Could not reach npm registry to check package ages. fresh_package_version checks will be skipped.'); - this.registryWarningLogged = true; - } - this.registryUnavailable = true; - return null; - } - if (data?.time) { - this.npmPackageCache.set(pkgName, data.time); - } - const versionTime = data?.time?.[version]; - if (!versionTime) return null; - const publishTime = new Date(versionTime).getTime(); - return { - version, - time: versionTime, - ageMs: now - publishTime - }; - } - - /** - * Generate a detailed report of findings - */ - generateReport(quiet = false) { - console.log('πŸ“Š AI Slop Detection Report'); - console.log('============================\n'); - if (this.issues.length === 0) { - console.log('βœ… No AI Slop issues detected!'); - return; - } - - // Group issues by severity - const bySeverity = { - critical: this.issues.filter(i => i.severity === 'critical'), - high: this.issues.filter(i => i.severity === 'high'), - medium: this.issues.filter(i => i.severity === 'medium'), - low: this.issues.filter(i => i.severity === 'low') - }; - console.log(`Found ${this.issues.length} AI Slop issues:`); - console.log(` Critical: ${bySeverity.critical.length}`); - console.log(` High: ${bySeverity.high.length}`); - console.log(` Medium: ${bySeverity.medium.length}`); - console.log(` Low: ${bySeverity.low.length}\n`); - - // Display top issues by severity - ['critical', 'high', 'medium', 'low'].forEach(severity => { - const issues = bySeverity[severity]; - if (issues.length > 0) { - console.log(`\n${severity.toUpperCase()} SEVERITY ISSUES:`); - console.log(''.padStart(80, '-')); - - // Group by type for better organization - const byType = {}; - issues.slice(0, 20).forEach(issue => { - if (!byType[issue.type]) { - byType[issue.type] = []; - } - byType[issue.type].push(issue); - }); - Object.entries(byType).forEach(([type, typeIssues]) => { - const sampleIssue = typeIssues[0]; - // Find the pattern to get fix and learnMore info - const patternInfo = this.detectionPatterns.find(p => p.id === type); - console.log(`\nπŸ“ Pattern: ${type}`); - console.log(` Description: ${sampleIssue.message.split('(').pop()?.replace(')', '') || ''}`); - - // Phase 2: Show fix suggestions and learn more links - if (patternInfo?.fix) { - console.log(` πŸ’‘ Fix: ${patternInfo.fix}`); - } - if (patternInfo?.learnMore) { - console.log(` πŸ“š Learn more: ${patternInfo.learnMore}`); - } - console.log(` Sample occurrences: ${typeIssues.length}`); - - // Show a few specific examples - typeIssues.slice(0, 3).forEach(issue => { - const relativePath = path.relative(this.rootDir, issue.file); - console.log(` β†’ ${relativePath}:${issue.line} - ${issue.code}`); - }); - if (typeIssues.length > 3) { - console.log(` ... and ${typeIssues.length - 3} more instances`); - } - }); - if (issues.length > 20) { - console.log(`\n ... and ${issues.length - 20} more issues of this severity`); - } - } - }); - - // Provide summary statistics - console.log(`\nπŸ“ˆ SUMMARY STATISTICS:`); - console.log(''.padStart(80, '-')); - - // Count by type - const byType = {}; - this.issues.forEach(issue => { - byType[issue.type] = (byType[issue.type] || 0) + 1; - }); - console.log('\nIssues by type:'); - Object.entries(byType).sort((a, b) => b[1] - a[1]) // Sort by count - .slice(0, 10).forEach(([type, count]) => { - console.log(` ${type}: ${count}`); - }); - - // Files with most issues - show core application files separately - const fileCounts = {}; - this.issues.forEach(issue => { - fileCounts[issue.file] = (fileCounts[issue.file] || 0) + 1; - }); - - // Split files into core app files and others - const allFiles = Object.entries(fileCounts); - const coreAppFiles = allFiles.filter(([file]) => { - const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); - return this.coreAppDirs.some(dir => relativePath.startsWith(dir)); - }); - const otherFiles = allFiles.filter(([file]) => { - const relativePath = path.relative(this.rootDir, file).replace(/\\/g, '/'); - return !this.coreAppDirs.some(dir => relativePath.startsWith(dir)); - }); - - // Show core application files separately - const topCoreFiles = coreAppFiles.sort((a, b) => b[1] - a[1]).slice(0, 10); - console.log('\nTop CORE APPLICATION files with AI Slop issues:'); - if (topCoreFiles.length > 0) { - topCoreFiles.forEach(([file, count]) => { - const relativePath = path.relative(this.rootDir, file); - console.log(` ${relativePath}: ${count} issues β˜…`); - }); - } else { - console.log(' No core application files found with issues'); - } - - // In quiet mode, don't show other files (tests, scripts, mocks, etc.) - if (!quiet) { - // Also show other notable files if there's space - const topOtherFiles = otherFiles.sort((a, b) => b[1] - a[1]).slice(0, 5); - if (topOtherFiles.length > 0) { - console.log('\nTop OTHER files with AI Slop issues (utilities, scripts, etc.):'); - topOtherFiles.forEach(([file, count]) => { - const relativePath = path.relative(this.rootDir, file); - console.log(` ${relativePath}: ${count} issues`); - }); - } - } - - // Add KarpeSlop scoring - const score = this.calculateKarpeSlopScore(); - console.log(`\nKARPATHY SLOP INDEXβ„’`); - console.log('═'.repeat(50)); - console.log(`Information Utility (Noise) : ${score.informationUtility} pts`); - console.log(`Information Quality (Lies) : ${score.informationQuality} pts`); - console.log(`Style / Taste (Soul) : ${score.style} pts`); - console.log(`TOTAL KARPE-SLOP SCORE : ${score.total} pts`); - if (hasOnlyFreshPackageWarnings(this.issues)) { - console.log(`\nPackage freshness warnings only. Not counted in the KarpeSlop score.`); - } else if (score.total > 50) { - console.log(`\nSUEEEY! Here piggy piggy... this codebase is 100% slop-fed.`); - } else { - console.log(`\nAcceptable. But Karpathy is watching.`); - } - console.log('\nπŸ”§ Next Steps:'); - console.log('============='); - console.log('1. Address critical and high severity issues first'); - console.log('2. Focus on removing `any` types and replacing with proper types'); - console.log('3. Add proper error handling to asynchronous operations'); - console.log('4. Refactor complex functions for better readability'); - console.log('5. Remove development artifacts like TODO comments and console logs'); - } - - /** - * Get the current configuration - */ - getConfig() { - return { - ...this.config - }; - } - - /** - * Get the number of issues found - */ - getIssueCount() { - return this.issues.length; - } - - /** - * Get issues by severity level - */ - getIssuesBySeverity(severity) { - return this.issues.filter(issue => issue.severity === severity); - } - - /** - * Consolidate issues by grouping identical issues (same type, file, code, message, severity) - * into single entries with a location array - */ - consolidateIssues() { - const issueMap = new Map(); - for (const issue of this.issues) { - // Create a unique key for grouping identical issues - const key = `${issue.type}|${issue.file}|${issue.code}|${issue.message}|${issue.severity}`; - if (issueMap.has(key)) { - // Add location to existing consolidated issue - const existing = issueMap.get(key); - existing.location.push(`${issue.line}:${issue.column}`); - } else { - // Create new consolidated issue - issueMap.set(key, { - type: issue.type, - file: issue.file, - code: issue.code, - message: issue.message, - severity: issue.severity, - location: [`${issue.line}:${issue.column}`] - }); - } - } - return Array.from(issueMap.values()); - } - - /** - * Export results to JSON for further processing - */ - exportResults(outputPath) { - // Consolidate issues to avoid repetition - const consolidatedIssues = this.consolidateIssues(); - - // Helper to count occurrences from consolidated issues - const countOccurrences = issues => issues.reduce((sum, issue) => sum + issue.location.length, 0); - - // Group consolidated issues by severity - const consolidatedBySeverity = { - critical: consolidatedIssues.filter(i => i.severity === 'critical'), - high: consolidatedIssues.filter(i => i.severity === 'high'), - medium: consolidatedIssues.filter(i => i.severity === 'medium'), - low: consolidatedIssues.filter(i => i.severity === 'low') - }; - - // Count total occurrences (sum of all locations) - const totalOccurrences = countOccurrences(consolidatedIssues); - - // Group by type and count occurrences - const byTypeMap = new Map(); - for (const issue of consolidatedIssues) { - if (!byTypeMap.has(issue.type)) { - byTypeMap.set(issue.type, []); - } - byTypeMap.get(issue.type).push(issue); - } - const results = { - timestamp: new Date().toISOString(), - // Unique consolidated issues count - uniqueIssues: consolidatedIssues.length, - // Total occurrences (backwards compatible - same as old totalIssues) - totalOccurrences: totalOccurrences, - // Occurrence counts by severity (backwards compatible) - bySeverity: { - critical: countOccurrences(consolidatedBySeverity.critical), - high: countOccurrences(consolidatedBySeverity.high), - medium: countOccurrences(consolidatedBySeverity.medium), - low: countOccurrences(consolidatedBySeverity.low) - }, - // Occurrence counts by type (backwards compatible) - byType: Array.from(byTypeMap.entries()).map(([type, issues]) => ({ - type, - // Total occurrences of this issue type - occurrences: countOccurrences(issues), - // Unique consolidated issues of this type - uniqueIssues: issues.length, - sample: issues.slice(0, 3).map(issue => ({ - file: path.relative(this.rootDir, issue.file), - locations: issue.location.slice(0, 3), - code: issue.code - })) - })), - // Consolidated issues array (new format with location arrays) - issues: consolidatedIssues - }; - fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); - console.log(`\nπŸ“ˆ Results exported to: ${outputPath}`); - } - - /** - * Calculate comprehensive KarpeSlop score based on the three axes - */ - calculateKarpeSlopScore() { - const weights = { - // Critical hallucinations = instant fail - hallucinated_react_import: 30, - hallucinated_next_import: 30, - // Type system poison - any_type_usage: 15, - unsafe_type_assertion: 12, - unsafe_double_type_assertion: 12, - // Soul death - overconfident_comment: 10, - hedging_uncertainty_comment: 10, - todo_implementation_placeholder: 12, - assumption_comment: 11, - // Noise & bloat - redundant_self_explanatory_comment: 8, - excessive_boilerplate_comment: 6, - debug_log_with_comment: 5, - // Vibe crimes - unnecessary_iife_wrapper: 7, - vibe_coded_ternary_abuse: 6 - }; - let utility = 0, - quality = 0, - style = 0; - for (const i of this.issues) { - // Package freshness is tracked separately from AI slop scoring. - if (i.type === 'fresh_package_version') { - continue; - } - const w = weights[i.type] || 3; - if (i.type.includes('hallucinated') || i.type.includes('todo') || i.type.includes('assumption')) quality += w;else if (i.type.includes('comment') || i.type.includes('redundant') || i.type.includes('boilerplate')) utility += w;else style += w; - } - const total = utility + quality + style; - return { - informationUtility: utility, - informationQuality: quality, - style, - total - }; - } -} - -// Run the detector if this script is executed directly -export function splitCliArgs(args) { - // Support -- separator: everything after -- is treated as a path, even if it starts with - - const doubleDashIdx = args.indexOf('--'); - if (doubleDashIdx !== -1) { - return { - flagArgs: args.slice(0, doubleDashIdx), - targetPaths: args.slice(doubleDashIdx + 1) - }; - } - return { - flagArgs: args.filter(a => a.startsWith('-')), - targetPaths: args.filter(a => !a.startsWith('-')) - }; -} -async function runIfMain() { - const rootDir = process.cwd(); - const args = process.argv.slice(2); - const { - flagArgs, - targetPaths - } = splitCliArgs(args); - - // Check for help options first, before constructing the detector - // (so a bad config doesn't break --help) - if (flagArgs.includes('--help') || flagArgs.includes('-h') || flagArgs.includes('/?')) { - console.log(` -Usage: karpeslop [options] [path...] - -Arguments: - path... File or directory path(s) to scan (scans all files if omitted) - -Options: - --help, -h Show this help message - --quiet, -q Run in quiet mode (only scan core app files) - --strict, -s Exit with code 2 if critical issues (hallucinations) are found - --version, -v Show version information - -- End of flags; treat remaining args as paths (even if they start with -) - -Exit Codes: - 0 - No issues found - 1 - Issues found (warnings/errors) - 2 - Critical issues found (--strict mode only) - -Examples: - karpeslop # Scan all files in current directory - karpeslop src/app.ts # Scan a single file - karpeslop src/lib/ # Scan a directory - karpeslop src/app.ts src/lib/ # Scan specific paths - karpeslop --quiet # Scan only core application files - karpeslop --strict src/app.ts # Block on critical issues in a specific file - karpeslop -- -dash-file.ts # Scan a file whose name starts with - - karpeslop --help # Show this help - -The tool detects the three axes of AI slop: - 1. Information Utility (Noise) - Comments, boilerplate, etc. - 2. Information Quality (Lies) - Hallucinated imports, assumptions, etc. - 3. Style / Taste (Soul) - Overconfident comments, unnecessary complexity -`); - process.exit(0); - } - - // Check for version options - if (flagArgs.includes('--version') || flagArgs.includes('-v')) { - // Try to get version from package.json - try { - const packagePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'); - const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - console.log(`karpeslop/${packageData.version} ${process.platform}-${process.arch} node-${process.version}`); - } catch { - console.log('karpeslop/unknown'); - } - process.exit(0); - } - const detector = new AISlopDetector(rootDir, targetPaths.length > 0 ? targetPaths : undefined); - const quiet = flagArgs.includes('--quiet') || flagArgs.includes('-q'); - const strict = flagArgs.includes('--strict') || flagArgs.includes('-s') || !!detector.getConfig().blockOnCritical; - try { - const issues = await detector.detect(quiet); - // Export results to a JSON file for CI/CD integration - const outputPath = path.join(rootDir, 'ai-slop-report.json'); - detector.exportResults(outputPath); - - // In strict mode, exit with code 2 if there are any critical issues (hallucinations) - const criticalIssues = issues.filter(i => i.severity === 'critical'); - if (strict && criticalIssues.length > 0) { - if (targetPaths.length > 0) { - const criticalFiles = [...new Set(criticalIssues.map(i => path.relative(rootDir, i.file)))]; - console.log(`\n❌ STRICT MODE: ${criticalIssues.length} CRITICAL issue(s) found in: ${criticalFiles.join(', ')}`); - } else { - console.log(`\n❌ STRICT MODE: ${criticalIssues.length} CRITICAL issue(s) found. Blocking.`); - } - process.exit(2); - } - const exitCode = hasOnlyFreshPackageWarnings(issues) ? 0 : issues.length > 0 ? 1 : 0; - process.exit(exitCode); - } catch (error) { - console.error('πŸ’₯ AI Slop detection failed:', error); - process.exit(1); - } -} - -// Execute only when run as the main module, not when imported by tests. -if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { - runIfMain().catch(error => { - console.error('πŸ’₯ AI Slop detection failed:', error); - process.exit(1); - }); -} -export { AISlopDetector }; diff --git a/karpeslop.js b/karpeslop.js deleted file mode 100644 index 40715f9..0000000 --- a/karpeslop.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import { spawn } from 'child_process'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Find the tsx executable in node_modules -const tsxPath = join(__dirname, 'node_modules', '.bin', 'tsx'); -const tsxPathWindows = join(__dirname, 'node_modules', '.bin', 'tsx.cmd'); - -// Check if we're on Windows -const isWindows = process.platform === 'win32'; -const command = isWindows ? tsxPathWindows : tsxPath; - -// Use spawn to execute tsx with the TypeScript file -const child = spawn(command, [join(__dirname, 'ai-slop-detector.ts'), ...process.argv.slice(2)], { - stdio: 'inherit', - cwd: process.cwd() -}); - -child.on('error', (err) => { - console.error('Failed to start karpeslop:', err.message); - - // Fallback: try running with node --import if tsx isn't available - if (err.code === 'ENOENT') { - console.error('tsx not found, attempting fallback method...'); - const nodeChild = spawn('node', ['--import', 'tsx', join(__dirname, 'ai-slop-detector.ts'), ...process.argv.slice(2)], { - stdio: 'inherit', - cwd: process.cwd() - }); - - nodeChild.on('error', (nodeErr) => { - console.error('Fallback method also failed:', nodeErr.message); - process.exit(1); - }); - } else { - process.exit(1); - } -}); - -child.on('exit', (code) => { - process.exit(code || 0); -}); \ No newline at end of file diff --git a/package.json b/package.json index 3a7d1fe..992adf4 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,8 @@ "slop:report": "tsx ai-slop-detector.ts && cat ai-slop-report.json" }, "files": [ - "karpeslop-bin.js", "ai-slop-detector.ts", "karpeslop-cli.js", - "karpeslop.js", "README.md", "CHANGELOG.md", "package.json", diff --git a/tsconfig.json b/tsconfig.json index d422c01..341c39c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,6 @@ ], "exclude": [ "node_modules", - "dist", - "karpeslop.js" + "dist" ] } \ No newline at end of file From cc3f0c4b5c1a54e7b858f609c92cae5e64405fd4 Mon Sep 17 00:00:00 2001 From: Daniel King Date: Wed, 10 Jun 2026 13:15:50 -0400 Subject: [PATCH 38/38] fix: resolve tsx from package deps instead of hardcoded .bin path; update exit-code docs - karpeslop-cli.js: use createRequire to resolve tsx loader from this package's dependency graph instead of assuming a package-local node_modules/.bin/tsx path, fixing ENOENT in normal installs - ai-slop-detector.ts: update help text exit-code section to reflect that fresh_package_version findings exit 0 - README.md: update exit-code docs to match actual behavior - CHANGELOG.md: document both fixes --- CHANGELOG.md | 2 ++ README.md | 4 ++-- ai-slop-detector.ts | 4 ++-- karpeslop-cli.js | 37 +++++++++---------------------------- 4 files changed, 15 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c375a61..75173cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ All notable changes will be documented in this file. ### Fixed - **package-lock.json bin entry**: Aligned to `karpeslop-cli.js` (matching `package.json`). +- **Published CLI wrapper**: `karpeslop-cli.js` now resolves `tsx` from the package dependency graph instead of assuming a package-local `.bin/tsx` path, so normal installs no longer print an `ENOENT` before startup. - **Config validation**: `minPackageAgeDays` now rejects non-finite/negative values at load time instead of silently producing NaN. +- **Exit code docs**: Help text and `README.md` now document that `fresh_package_version` findings are informational and exit with code `0` when they are the only issues. ### Refactored - Shared `getGlobIgnorePatterns()` and `isExcludedPath()` between `findAllFiles()` and `resolveTargetPaths()` so both paths use the same exclusion logic. diff --git a/README.md b/README.md index 764bd04..25723c5 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ npx karpeslop@latest --version ### Exit Codes -- `0`: No issues found -- `1`: Issues found (warnings) +- `0`: No issues found, or only `fresh_package_version` warnings were found +- `1`: Blocking issues found (warnings other than `fresh_package_version`) - `2`: Critical issues found (with `--strict` flag) β€” blocks CI ### The Three Axes of AI Slop diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index a75dda9..c6286df 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -1752,8 +1752,8 @@ Options: -- End of flags; treat remaining args as paths (even if they start with -) Exit Codes: - 0 - No issues found - 1 - Issues found (warnings/errors) + 0 - No issues found, or only fresh package warnings were found + 1 - Blocking issues found (warnings/errors other than fresh package warnings) 2 - Critical issues found (--strict mode only) Examples: diff --git a/karpeslop-cli.js b/karpeslop-cli.js index 4a39d1e..af462fb 100644 --- a/karpeslop-cli.js +++ b/karpeslop-cli.js @@ -2,20 +2,18 @@ // This is an ES module wrapper for the KarpeSlop CLI tool import { spawn } from 'child_process'; +import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +const require = createRequire(import.meta.url); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Get the path to the TypeScript file and local tsx binary +// Resolve the tsx loader from this package's dependencies so published +// installs do not depend on a specific node_modules/.bin layout. const detectorPath = join(__dirname, 'ai-slop-detector.ts'); -const tsxPath = join(__dirname, 'node_modules', '.bin', 'tsx'); -const tsxPathWin = join(__dirname, 'node_modules', '.bin', 'tsx.cmd'); - -// Check if we're on Windows -const isWindows = process.platform === 'win32'; -const tsxCommand = isWindows ? tsxPathWin : tsxPath; +const tsxLoaderPath = require.resolve('tsx'); function exitCodeForSignal(signal) { const signalExitCodes = { @@ -38,33 +36,16 @@ function handleChildExit(code, signal) { // Get command line arguments, excluding the first two (node and script path) const args = process.argv.slice(2); -// Run with local tsx +// Run the detector through Node with the resolved tsx loader. const child = spawn( - tsxCommand, - [detectorPath, ...args], + process.execPath, + ['--import', tsxLoaderPath, detectorPath, ...args], { stdio: 'inherit', cwd: process.cwd() } ); child.on('error', (err) => { console.error('Failed to start karpeslop:', err.message); - - // Fallback: try to run via node with tsx import - if (err.code === 'ENOENT') { - const nodeChild = spawn( - 'node', - ['--import', 'tsx', detectorPath, ...args], - { stdio: 'inherit', cwd: process.cwd() } - ); - - nodeChild.on('error', (nodeErr) => { - console.error('Fallback execution also failed:', nodeErr.message); - process.exit(1); - }); - - nodeChild.on('exit', handleChildExit); - } else { - process.exit(1); - } + process.exit(1); }); child.on('exit', handleChildExit);