diff --git a/CHANGELOG.md b/CHANGELOG.md index 42de970..c3d9f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes will be documented in this file. +## [1.0.28] - 2026-07-27 + +### Added +- **AST-based unsafe assertion detector**: `findUnsafeAssertions()` uses the TypeScript Compiler API to detect three assertion forms that bypass structural type safety: + - `unsafe_double_type_assertion` — `expr as unknown as T` (chained `unknown` cast). Previously suppressed as "safe"; now reports at `high` severity by default. + - `unsafe_object_assertion` — `expr as Record<...>` or `expr as { ... }`. + - `unsafe_array_assertion` — `expr as T[]` or `expr as Array`. +- **Configurable severity**: All three new patterns respect the existing `severityOverrides` mechanism in `.karpesloprc.json`. +- **Exclusions**: No reporting for `as const`, `as typeof`, `as keyof`, `.d.ts` files, or lines guarded by `@ts-expect-error`/`eslint-disable-next-line`. +- **Regression fixtures**: `tests/fixtures/unsafe-assertions.ts` with the issue #22 reproduction snippet plus safe-cast boundary tests. + +### Changed +- **BREAKING**: `as unknown as T` is no longer treated as a guaranteed-safe pattern. It now produces a `high`-severity finding (`unsafe_double_type_assertion`). Silence it project-wide via `severityOverrides: { "unsafe_double_type_assertion": "off" }` in `.karpesloprc.json`. +- **Removed** the regex-based `unsafe_double_type_assertion` pattern and its heuristic skip block (English-word false-positive filter). The AST visitor naturally excludes comments and non-assertion text, making the heuristic unnecessary. +- `tests/fixtures/false-positives.ts`: Removed the `as unknown as Type` "safe" assertion block (lines referencing `someValue as unknown as string` / `jsonValue as unknown as User[]`). + ## [1.0.27] - 2026-06-16 ### Changed diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index c6286df..e459b9a 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -13,6 +13,7 @@ import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import { fileURLToPath } from 'url'; +import ts from 'typescript'; const globSync = glob.sync; const globEscape = glob.escape; @@ -34,6 +35,17 @@ interface AISlopIssue { severity: 'critical' | 'high' | 'medium' | 'low'; } +interface UnsafeAssertionFinding { + type: string; + file: string; + line: number; + column: number; + code: string; + message: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + assertionForm: string; +} + interface ConsolidatedIssue { type: string; file: string; @@ -47,7 +59,7 @@ interface DetectionPattern { id: string; pattern: RegExp; message: string; - severity: AISlopIssue['severity']; + severity: AISlopIssue['severity'] | 'off'; description: string; fix?: string; // Phase 2: How to fix this issue learnMore?: string; // Phase 2: Link to documentation @@ -69,7 +81,7 @@ interface CustomPatternConfig { interface KarpeSlopConfig { customPatterns?: CustomPatternConfig[]; ignorePaths?: string[]; - severityOverrides?: Record; + severityOverrides?: Record; blockOnCritical?: boolean; minPackageAgeDays?: number; } @@ -160,6 +172,109 @@ export function isExcludedPath(filePath: string, rootDir: string, allowOutsideRo pathToMatch.endsWith('improved-ai-slop-detector.ts'))); } +export function findUnsafeAssertions(sourceText: string, fileName: string): UnsafeAssertionFinding[] { + if (fileName.endsWith('.d.ts')) return []; + + const scriptKind = fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, scriptKind); + const findings: UnsafeAssertionFinding[] = []; + const lines = sourceText.split('\n'); + + const lineIsSuppressed = (line: number): boolean => { + if (line <= 1) return false; + const prevLine = lines[line - 2]; + const currentLine = lines[line - 1]; + return ( + prevLine.includes('@ts-expect-error') || + prevLine.includes('@ts-ignore') || + prevLine.includes('eslint-disable-next-line') || + prevLine.includes('eslint-disable') || + currentLine.includes('@ts-expect-error') || + currentLine.includes('@ts-ignore') || + currentLine.includes('eslint-disable-line') || + currentLine.includes('eslint-disable') + ); + }; + + // Syntax-only (non-type-checked) detection: matches by identifier text, not + // resolved symbol. A local type named Record or Array shadowing the built-in + // utility types would produce a false positive. Accepted trade-off for a + // syntax-level tool. + function isUnsafeObjectType(type: ts.TypeNode): boolean { + if (ts.isTypeReferenceNode(type)) { + const name = ts.isIdentifier(type.typeName) ? type.typeName.text : ''; + if (name === 'Record') return true; + } + if (ts.isTypeLiteralNode(type)) return true; + return false; +} + +function isArrayType(type: ts.TypeNode): boolean { + if (ts.isArrayTypeNode(type)) return true; + if (ts.isTypeReferenceNode(type)) { + const name = ts.isIdentifier(type.typeName) ? type.typeName.text : ''; + if (name === 'Array' && type.typeArguments && type.typeArguments.length > 0) return true; + } + return false; +} + +function visit(node: ts.Node) { + if (ts.isAsExpression(node)) { + const { line: lineIdx, character: charIdx } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + const sourceLine = lineIdx + 1; + const column = charIdx + 1; + + if (lineIsSuppressed(sourceLine)) { + ts.forEachChild(node, visit); + return; + } + + if (ts.isAsExpression(node.expression) && node.expression.type.kind === ts.SyntaxKind.UnknownKeyword) { + const code = sourceText.slice(node.getStart(sourceFile), node.end); + findings.push({ + type: 'unsafe_double_type_assertion', + file: fileName, + line: sourceLine, + column, + code, + message: "Found unsafe double type assertion via 'as unknown as'. Use proper type guards or validation instead.", + severity: 'high', + assertionForm: 'as_unknown_as', + }); + } else if (isUnsafeObjectType(node.type)) { + const code = sourceText.slice(node.getStart(sourceFile), node.end); + findings.push({ + type: 'unsafe_object_assertion', + file: fileName, + line: sourceLine, + column, + code, + message: 'Found unsafe object type assertion. Use proper type guards or runtime validation instead.', + severity: 'high', + assertionForm: 'as_object', + }); + } else if (isArrayType(node.type)) { + const code = sourceText.slice(node.getStart(sourceFile), node.end); + findings.push({ + type: 'unsafe_array_assertion', + file: fileName, + line: sourceLine, + column, + code, + message: 'Found unsafe array type assertion. Use proper type guards or runtime validation instead.', + severity: 'high', + assertionForm: 'as_array', + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return findings; +} + class AISlopDetector { private issues: AISlopIssue[] = []; private targetExtensions = ['.ts', '.tsx', '.js', '.jsx']; @@ -345,13 +460,6 @@ class AISlopDetector { 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, @@ -392,6 +500,27 @@ class AISlopDetector { message: "Found potentially unsafe member access on 'any' type.", severity: 'high', description: 'Detects unsafe member access patterns' + }, + { + id: 'unsafe_double_type_assertion', + pattern: /unused_ast_detected/, + message: "Found unsafe double type assertion via 'as unknown as'. Use proper type guards or runtime validation.", + severity: 'high', + description: 'Detects as unknown as T (AST-based)' + }, + { + id: 'unsafe_object_assertion', + pattern: /unused_ast_detected/, + message: "Found unsafe object type assertion. Use proper type guards or runtime validation.", + severity: 'high', + description: 'Detects as Record<...> or as { ... } (AST-based)' + }, + { + id: 'unsafe_array_assertion', + pattern: /unused_ast_detected/, + message: "Found unsafe array type assertion. Use proper type guards or runtime validation.", + severity: 'high', + description: 'Detects as T[] or as Array (AST-based)' } ]; @@ -421,7 +550,8 @@ class AISlopDetector { throw new Error('Config must be an object'); } - const validSeverities = ['critical', 'high', 'medium', 'low']; + const validOverrideSeverities = ['critical', 'high', 'medium', 'low', 'off']; + const validPatternSeverities = ['critical', 'high', 'medium', 'low']; const cfg = config as Record; // Validate customPatterns @@ -440,8 +570,8 @@ class AISlopDetector { if (!pattern.message || typeof pattern.message !== 'string') { throw new Error(`customPatterns[${i}].message must be a string`); } - if (!pattern.severity || !validSeverities.includes(pattern.severity as string)) { - throw new Error(`customPatterns[${i}].severity must be one of: ${validSeverities.join(', ')}`); + if (!pattern.severity || !validPatternSeverities.includes(pattern.severity as string)) { + throw new Error(`customPatterns[${i}].severity must be one of: ${validPatternSeverities.join(', ')}`); } // Validate regex is valid try { @@ -458,8 +588,8 @@ class AISlopDetector { throw new Error('severityOverrides must be an object'); } for (const [key, value] of Object.entries(cfg.severityOverrides as Record)) { - if (!validSeverities.includes(value as string)) { - throw new Error(`severityOverrides.${key} must be one of: ${validSeverities.join(', ')}`); + if (!validOverrideSeverities.includes(value as string)) { + throw new Error(`severityOverrides.${key} must be one of: ${validOverrideSeverities.join(', ')}`); } } } @@ -845,6 +975,16 @@ class AISlopDetector { continue; } + // Skip AST-based patterns (detected by findUnsafeAssertions, not regex) + if (pattern.id === 'unsafe_double_type_assertion' || + pattern.id === 'unsafe_object_assertion' || + pattern.id === 'unsafe_array_assertion') { + continue; + } + + // Skip disabled patterns early to avoid unnecessary regex work + if (pattern.severity === 'off') continue; + // Create a new RegExp object for each check to reset lastIndex const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags); let match; @@ -929,27 +1069,6 @@ class AISlopDetector { } } - // 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(); @@ -1041,6 +1160,41 @@ class AISlopDetector { } + // AST-based unsafe assertion detection (.ts/.tsx only) + if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { + const skipInQuiet = quiet && ( + filePath.includes('__tests__') || + filePath.includes('.test.') || + filePath.includes('.spec.') || + filePath.includes('__mocks__') || + filePath.includes('test-') + ); + + if (!skipInQuiet) { + const astFindings = findUnsafeAssertions(content, filePath); + for (const finding of astFindings) { + const pattern = this.detectionPatterns.find(p => p.id === finding.type); + if (pattern && ((pattern.skipTests && isTestFile) || (pattern.skipMocks && isMockFile))) { + continue; + } + const effectiveSeverity = pattern ? pattern.severity : finding.severity; + if (effectiveSeverity === 'off') continue; + + this.issues.push({ + type: finding.type, + file: finding.file, + line: finding.line, + column: finding.column, + code: finding.code, + message: pattern + ? `${pattern.message} (${pattern.description})` + : finding.message, + severity: effectiveSeverity, + }); + } + } + } + // 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); @@ -1683,6 +1837,8 @@ class AISlopDetector { any_type_usage: 15, unsafe_type_assertion: 12, unsafe_double_type_assertion: 12, + unsafe_object_assertion: 12, + unsafe_array_assertion: 12, // Soul death overconfident_comment: 10, @@ -1825,4 +1981,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.a }); } -export { AISlopDetector, AISlopIssue, ConsolidatedIssue, DetectionPattern }; +export { AISlopDetector, AISlopIssue, ConsolidatedIssue, DetectionPattern, UnsafeAssertionFinding }; diff --git a/package-lock.json b/package-lock.json index d2ca80f..bdecd1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "karpeslop", - "version": "1.0.26", + "version": "1.0.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.26", + "version": "1.0.28", "license": "MIT", "dependencies": { "glob": "13.0.6", - "tsx": "4.22.4" + "tsx": "4.22.4", + "typescript": "6.0.3" }, "bin": { "karpeslop": "karpeslop-cli.js" @@ -19,8 +20,7 @@ "@babel/cli": "7.29.7", "@babel/core": "7.29.7", "@babel/preset-typescript": "7.29.7", - "@types/node": "25.9.2", - "typescript": "6.0.3" + "@types/node": "25.9.2" } }, "node_modules/@babel/cli": { @@ -1698,7 +1698,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 7ec956f..7a29409 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.27", + "version": "1.0.28", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "packageManager": "npm@10.9.7", @@ -50,7 +50,8 @@ "llmsFull": "KarpeSlop is a static analysis tool for detecting 'AI slop' in TypeScript and JavaScript code. It identifies three categories of problems: (1) Information Utility - noise like redundant comments and console logs, (2) Information Quality - lies like hallucinated imports from wrong packages, (3) Style/Taste - soul-less patterns like overconfident comments and vibe coding. Run with: npx karpeslop@latest. Use --strict for CI/CD to block on critical issues. Outputs a Karpe-Slop Index score.", "dependencies": { "glob": "13.0.6", - "tsx": "4.22.4" + "tsx": "4.22.4", + "typescript": "6.0.3" }, "repository": { "type": "git", @@ -60,8 +61,7 @@ "@babel/cli": "7.29.7", "@babel/core": "7.29.7", "@babel/preset-typescript": "7.29.7", - "@types/node": "25.9.2", - "typescript": "6.0.3" + "@types/node": "25.9.2" }, "overrides": { "@babel/cli": { diff --git a/tests/ai-slop-detector.behavior.test.ts b/tests/ai-slop-detector.behavior.test.ts index 018d47e..8cb3482 100644 --- a/tests/ai-slop-detector.behavior.test.ts +++ b/tests/ai-slop-detector.behavior.test.ts @@ -5,6 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { + findUnsafeAssertions, hasOnlyFreshPackageWarnings, isExcludedPath, isRegistryBackedLockfileEntry, @@ -123,6 +124,118 @@ test('--strict exits with code 2 when critical hallucination is found', () => { } }); +test('severityOverrides with "off" silences unsafe_double_type_assertion without crashing', () => { + const fixtureFile = path.resolve(process.cwd(), 'tests/fixtures/temp-off-fixture.ts'); + const configFile = path.resolve(process.cwd(), '.karpesloprc.json'); + const savedConfig = fs.readFileSync(configFile, 'utf-8'); + + fs.writeFileSync(fixtureFile, 'const x = value as unknown as Foo;\n', 'utf-8'); + + try { + const overriddenConfig = JSON.parse(savedConfig); + overriddenConfig.severityOverrides = { "unsafe_double_type_assertion": "off" }; + fs.writeFileSync(configFile, JSON.stringify(overriddenConfig), '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() } + ); + + assert.equal(result.status, 0, `Expected exit code 0 but got ${result.status}. stdout:"${result.stdout}" stderr:"${result.stderr}"`); + assert.ok(!result.stdout.includes('unsafe_double'), 'should not mention the suppressed pattern'); + } finally { + fs.unlinkSync(fixtureFile); + fs.writeFileSync(configFile, savedConfig, 'utf-8'); + } +}); + +// --------------------------------------------------------------------------- +// Unsafe assertions (AST-based) +// --------------------------------------------------------------------------- + +test('findUnsafeAssertions does not report as const', () => { + assert.equal(findUnsafeAssertions('const x = 42 as const;', 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report as typeof x', () => { + assert.equal(findUnsafeAssertions('const x: typeof y = val as typeof y;', 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report as keyof T', () => { + assert.equal(findUnsafeAssertions('type K = keyof T; const k = v as keyof T;', 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report in .d.ts files', () => { + assert.equal(findUnsafeAssertions('const x = value as unknown as Foo;', 'types.d.ts').length, 0); +}); + +test('findUnsafeAssertions does not report when preceded by @ts-expect-error', () => { + const code = '// @ts-expect-error\nconst x = value as unknown as Foo;'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report when preceded by eslint-disable-next-line', () => { + const code = '// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst x = value as Record;'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions detects unsafe array assertion as EventRow[]', () => { + const code = 'const rows = data as EventRow[];'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 1); + assert.equal(findings[0].type, 'unsafe_array_assertion'); + assert.equal(findings[0].severity, 'high'); +}); + +test('findUnsafeAssertions detects unsafe object assertion as Record', () => { + const code = 'const record = value as Record;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 1); + assert.equal(findings[0].type, 'unsafe_object_assertion'); + assert.equal(findings[0].severity, 'high'); +}); + +test('findUnsafeAssertions on the fixtures/unsafe-assertions.ts fixture reports all three forms', () => { + const fixturePath = path.resolve(process.cwd(), 'tests/fixtures/unsafe-assertions.ts'); + const code = fs.readFileSync(fixturePath, 'utf-8'); + const findings = findUnsafeAssertions(code, 'unsafe-assertions.ts'); + + const types = findings.map(f => f.type); + assert.ok(types.includes('unsafe_double_type_assertion'), 'should report double assertion'); + assert.ok(types.includes('unsafe_array_assertion'), 'should report array assertion'); + assert.ok(types.includes('unsafe_object_assertion'), 'should report object assertion'); + + const doubleFindings = findings.filter(f => f.type === 'unsafe_double_type_assertion'); + assert.ok(doubleFindings.length >= 1); + assert.ok(doubleFindings[0].code.includes('as unknown as')); + + const arrFindings = findings.filter(f => f.type === 'unsafe_array_assertion'); + assert.equal(arrFindings.length, 1); + assert.ok(arrFindings[0].code.includes('EventRow[]')); + + const objFindings = findings.filter(f => f.type === 'unsafe_object_assertion'); + assert.equal(objFindings.length, 1); + assert.ok(objFindings[0].code.includes('Record')); +}); + +test('findUnsafeAssertions detects chained as unknown as T as unsafe_double_type_assertion', () => { + const code = 'const rows = value as unknown as EventRow[];'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 1); + const finding = findings[0]; + assert.equal(finding.type, 'unsafe_double_type_assertion'); + assert.equal(finding.line, 1); + assert.equal(finding.severity, 'high'); + assert.ok(finding.code.includes('as unknown as')); +}); + 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'); diff --git a/tests/fixtures/false-positives.ts b/tests/fixtures/false-positives.ts index 30abad4..b732795 100644 --- a/tests/fixtures/false-positives.ts +++ b/tests/fixtures/false-positives.ts @@ -156,15 +156,6 @@ async function fetchWithPromiseCatch() { .catch(err => console.error('Fetch failed:', err)); } -// ============================================================ -// Also: as unknown as Type is a SAFE double assertion -// ============================================================ - -const safeCast = someValue as unknown as string; -const data = jsonValue as unknown as User[]; - -interface User { id: string; name: string; } - export { useDataFetcher, fetchNearbyTrucks, diff --git a/tests/fixtures/unsafe-assertions.ts b/tests/fixtures/unsafe-assertions.ts new file mode 100644 index 0000000..270ec5b --- /dev/null +++ b/tests/fixtures/unsafe-assertions.ts @@ -0,0 +1,21 @@ +interface EventRow { + id: string; +} + +declare function getExternalData(): unknown; + +const value: unknown = getExternalData(); +const rows = value as unknown as EventRow[]; +const bareArray = value as EventRow[]; +const record = value as Record; + +interface UserProfile { name: string; } +declare const someValue: unknown; +declare const jsonValue: unknown; +declare function getMsg(): string; + +const safeCast = someValue as unknown as string; +const data = jsonValue as unknown as UserProfile[]; +const msg = getMsg(); + +export { EventRow, UserProfile };