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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..75173cb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +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`). +- **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. +- `isExcludedPath` flattened with De Morgan's law; segment-based check for dotfiles and `types/`. + +## [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/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 c3af2f4..c6286df 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; @@ -68,11 +71,99 @@ interface KarpeSlopConfig { ignorePaths?: string[]; severityOverrides?: Record; blockOnCritical?: boolean; + minPackageAgeDays?: number; +} + +/** + * 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: string): { actualVersion: string; isRange: boolean } { + 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: (() => 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](); + } + }); + await Promise.all(workers); + 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'); +} + +/** + * 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']; + private manifestFilenames = ['package.json', 'package-lock.json']; // Core application directories to prioritize in reporting private coreAppDirs = ['app/', 'components/', 'lib/', 'hooks/', 'services/']; @@ -306,9 +397,19 @@ class AISlopDetector { private config: KarpeSlopConfig = {}; private customIgnorePaths: string[] = []; + private npmPackageCache: Map> = new Map(); + private registryWarningLogged = false; + private registryUnavailable = false; + private reportedFreshPackageKeys = new Set(); + 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) + ); + } } /** @@ -375,6 +476,14 @@ class AISlopDetector { } } + // 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 as KarpeSlopConfig; } @@ -443,22 +552,30 @@ 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) { + 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(); - console.log(`πŸ“ Found ${allFiles.length} files to analyze (${filesToAnalyze.length} in ${quiet ? 'quiet' : 'full'} mode)\n`); + 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) { - this.analyzeFile(file, quiet); + await this.analyzeFile(file, quiet); } // 3. Report findings @@ -467,61 +584,131 @@ 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 shouldAnalyzeInQuietMode(filePath: string): boolean { + return shouldAnalyzePathInQuietMode(filePath, this.rootDir, this.coreAppDirs); + } + + private isIgnoredByConfig(filePath: string): boolean { + 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 + * Find all TypeScript/JavaScript files in the project, plus manifest files */ private findAllFiles(): string[] { const allFiles: string[] = []; 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 = 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 + */ + 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); + 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() */ @@ -629,7 +816,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 +1040,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 (path.basename(filePath) === 'package.json' || path.basename(filePath) === 'package-lock.json') { + await this.analyzePackageVersions(filePath, content); + } } /** @@ -996,6 +1188,197 @@ 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 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; + } + + 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 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 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({ 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({ 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. + */ + 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) { + // 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: { time: Record } | null = null; + try { + const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; + 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; + 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; + } + 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 */ @@ -1150,9 +1533,8 @@ class AISlopDetector { console.log(`Style / Taste (Soul) : ${score.style} pts`); console.log(`TOTAL KARPE-SLOP SCORE : ${score.total} pts`); - if (score.total === 0) { - console.log(`\nCLEAN. Even Andrej would approve.`); - console.log(` "This codebase has taste." β€” @karpathy, probably`); + 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 { @@ -1321,6 +1703,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; @@ -1334,34 +1720,51 @@ class AISlopDetector { } // Run the detector if this script is executed directly +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 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 detector = new AISlopDetector(rootDir); - // Parse command line arguments const args = process.argv.slice(2); + const { flagArgs, targetPaths } = splitCliArgs(args); - // Check for help options first - if (args.includes('--help') || args.includes('-h') || args.includes('/?')) { + // 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] +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) + 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: - 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 -- -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. @@ -1372,7 +1775,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'); @@ -1384,8 +1787,9 @@ 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 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); @@ -1396,11 +1800,16 @@ 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); } - 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); @@ -1408,12 +1817,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 deleted file mode 100755 index 078ce25..0000000 --- a/karpeslop-bin.js +++ /dev/null @@ -1,1236 +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'; - -// Phase 6: Configuration file support - -class AISlopDetector { - issues = []; - targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; - - // 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 = []; - constructor(rootDir) { - this.rootDir = rootDir; - this.loadConfig(); - } - - /** - * 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`); - } - } - } - 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'); - - // 1. Find all TypeScript/JavaScript files - const allFiles = this.findAllFiles(); - - // 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; - 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) { - this.analyzeFile(file, quiet); - } - - // 3. Report findings - this.generateReport(quiet); - return this.issues; - } - - /** - * Find all TypeScript/JavaScript files in the project - */ - findAllFiles() { - const allFiles = []; - 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/'); - }); - allFiles.push(...filteredFiles); - } - - // Remove duplicates and return - return [...new Set(allFiles)]; - } - - /** - * 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 - */ - 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); - } - } - - /** - * 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; - } - - /** - * 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 (score.total === 0) { - console.log(`\nCLEAN. Even Andrej would approve.`); - console.log(` "This codebase has taste." β€” @karpathy, probably`); - } 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) { - 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 -async function runIfMain() { - const rootDir = process.cwd(); - const detector = new AISlopDetector(rootDir); - - // Parse command line arguments - const args = process.argv.slice(2); - - // Check for help options first - if (args.includes('--help') || args.includes('-h') || args.includes('/?')) { - console.log(` -Usage: karpeslop [options] - -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 - -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 --quiet # Scan only core application files - karpeslop --strict # Block on critical issues (hallucinations) - 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 (args.includes('--version') || args.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 quiet = args.includes('--quiet') || args.includes('-q'); - const strict = args.includes('--strict') || args.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) { - console.log(`\n❌ STRICT MODE: ${criticalIssues.length} CRITICAL issue(s) found. Blocking.`); - process.exit(2); - } - const exitCode = issues.length > 0 ? 1 : 0; - process.exit(exitCode); - } catch (error) { - console.error('πŸ’₯ AI Slop detection failed:', error); - process.exit(1); - } -} - -// 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); -}); -export { AISlopDetector }; diff --git a/karpeslop-cli.js b/karpeslop-cli.js index af07d73..af462fb 100644 --- a/karpeslop-cli.js +++ b/karpeslop-cli.js @@ -2,51 +2,50 @@ // 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 = { + 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); -// 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); - }); - } else { - process.exit(1); - } + process.exit(1); }); -child.on('exit', (code) => { - process.exit(code || 0); -}); \ No newline at end of file +child.on('exit', handleChildExit); 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-lock.json b/package-lock.json index adf64c9..ebb59de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "karpeslop", - "version": "1.0.20", + "version": "1.0.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.20", + "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", diff --git a/package.json b/package.json index d04f151..992adf4 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,23 @@ { "name": "karpeslop", - "version": "1.0.20", + "version": "1.0.25", "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\"", + "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" }, "files": [ - "karpeslop-bin.js", "ai-slop-detector.ts", "karpeslop-cli.js", - "karpeslop.js", "README.md", + "CHANGELOG.md", "package.json", "LICENSE" ], @@ -62,4 +62,4 @@ "@types/node": "^24.10.1", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/tests/ai-slop-detector.behavior.test.ts b/tests/ai-slop-detector.behavior.test.ts new file mode 100644 index 0000000..018d47e --- /dev/null +++ b/tests/ai-slop-detector.behavior.test.ts @@ -0,0 +1,147 @@ +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, + 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/']; + + 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 + ); +}); + +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); + } +}); 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