diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d9f2d..28a7018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes will be documented in this file. +## [1.0.29] - 2026-07-27 + +### Changed +- **`unsafe_type_assertion` migrated from regex to AST detection**: `as any` assertions are now detected by the TypeScript Compiler API instead of a line-based regex. Comments and strings containing `as any` are no longer reported. Nested and multiline assertions are handled syntactically. The pattern ID and configuration key remain unchanged — existing `severityOverrides` continue to work. +- **`unsafe_double_type_assertion` broadened**: Now detects any chained assertion (`as X as Y`), not only `as unknown as Y`. The `assertionForm` metadata field changed from `as_unknown_as` to `chained_as`. The message no longer references `as unknown as`. +- **First-line inline suppressions fixed**: `// eslint-disable-line` and `@ts-ignore` on line 1 now suppress findings correctly. +- **Test-file exclusion restored**: `unsafe_type_assertion` is once again skipped in `isTestFile` paths (`.test.*`, `.spec.*`, `__tests__/`, `__mocks__/`, `test-`) during normal scans. The exclusion is narrower than the pre-migration heuristic (`includes('test')`), so paths merely containing `test` or `spec` (e.g. `tests/utils/helper.ts`, `my-test-utils.ts`) may now be analyzed. +- **`unsafe_type_assertion` guidance no longer recommends chained assertions**: The `fix` field previously told users to `Use 'as unknown as TargetType'`, which conflicted with the broadened `unsafe_double_type_assertion` rule. The new guidance advocates a migration-first strategy with runtime validation. +- **Multiline suppression anchored to the `any` token line**: Suppression checks for multiline `as any` assertions now evaluate the line containing the `any` keyword, not the outer `as` expression line. Inline suppressions on the `any` line work correctly. + ## [1.0.28] - 2026-07-27 ### Added diff --git a/ai-slop-detector.ts b/ai-slop-detector.ts index e459b9a..7b73481 100644 --- a/ai-slop-detector.ts +++ b/ai-slop-detector.ts @@ -181,19 +181,23 @@ export function findUnsafeAssertions(sourceText: string, fileName: string): Unsa 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') || + if ( currentLine.includes('@ts-expect-error') || currentLine.includes('@ts-ignore') || currentLine.includes('eslint-disable-line') || currentLine.includes('eslint-disable') - ); + ) return true; + if (line > 1) { + const prevLine = lines[line - 2]; + return ( + prevLine.includes('@ts-expect-error') || + prevLine.includes('@ts-ignore') || + prevLine.includes('eslint-disable-next-line') || + prevLine.includes('eslint-disable') + ); + } + return false; }; // Syntax-only (non-type-checked) detection: matches by identifier text, not @@ -224,12 +228,12 @@ function visit(node: ts.Node) { const sourceLine = lineIdx + 1; const column = charIdx + 1; - if (lineIsSuppressed(sourceLine)) { - ts.forEachChild(node, visit); - return; - } + if (ts.isAsExpression(node.expression)) { + 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', @@ -237,11 +241,53 @@ function visit(node: ts.Node) { line: sourceLine, column, code, - message: "Found unsafe double type assertion via 'as unknown as'. Use proper type guards or validation instead.", + message: "Found unsafe double type assertion. Use proper type guards or validation instead.", + severity: 'high', + assertionForm: 'chained_as', + }); + // When the target type is also `any`, report it independently so disabling + // unsafe_double_type_assertion doesn't silently hide the unsafe `as any`. + if (node.type.kind === ts.SyntaxKind.AnyKeyword) { + const { line: asLineIdx, character: asCharIdx } = sourceFile.getLineAndCharacterOfPosition(node.type.getStart(sourceFile)); + findings.push({ + type: 'unsafe_type_assertion', + file: fileName, + line: asLineIdx + 1, + column: asCharIdx + 1, + code, + message: "Found unsafe 'as any' type assertion. Use proper type guards or validation.", + severity: 'high', + assertionForm: 'as_any', + }); + } + } else if (node.type.kind === ts.SyntaxKind.AnyKeyword) { + // Use the position of the type node for correct line reporting and suppression anchoring + const { line: asLineIdx, character: asCharIdx } = sourceFile.getLineAndCharacterOfPosition(node.type.getStart(sourceFile)); + const asLine = asLineIdx + 1; + const asColumn = asCharIdx + 1; + + if (lineIsSuppressed(asLine)) { + ts.forEachChild(node, visit); + return; + } + + const code = sourceText.slice(node.getStart(sourceFile), node.end); + findings.push({ + type: 'unsafe_type_assertion', + file: fileName, + line: asLine, + column: asColumn, + code, + message: "Found unsafe 'as any' type assertion. Use proper type guards or validation.", severity: 'high', - assertionForm: 'as_unknown_as', + assertionForm: 'as_any', }); } else if (isUnsafeObjectType(node.type)) { + if (lineIsSuppressed(sourceLine)) { + ts.forEachChild(node, visit); + return; + } + const code = sourceText.slice(node.getStart(sourceFile), node.end); findings.push({ type: 'unsafe_object_assertion', @@ -254,6 +300,11 @@ function visit(node: ts.Node) { assertionForm: 'as_object', }); } else if (isArrayType(node.type)) { + if (lineIsSuppressed(sourceLine)) { + ts.forEachChild(node, visit); + return; + } + const code = sourceText.slice(node.getStart(sourceFile), node.end); findings.push({ type: 'unsafe_array_assertion', @@ -451,14 +502,16 @@ class AISlopDetector { severity: 'high', description: 'Detects function parameters with any type' }, + // AST-driven; regex is a never-match sentinel — detection is in findUnsafeAssertions { id: 'unsafe_type_assertion', - pattern: /\s+as\s+any\b/g, + pattern: /(?!)/, 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' + description: 'Detects unsafe as any assertions (AST-based)', + fix: "Prefer migrating the source/data contract first. Otherwise validate the value at the boundary with a runtime type guard or validation schema, then narrow the resulting unknown value. Do not use chained type assertions.", + learnMore: 'https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates', + skipTests: true, }, { id: 'index_signature_any', @@ -503,21 +556,21 @@ class AISlopDetector { }, { 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.", + pattern: /(?!)/, + message: "Found unsafe double type assertion. Use proper type guards or runtime validation.", severity: 'high', - description: 'Detects as unknown as T (AST-based)' + description: 'Detects chained type assertions (AST-based)' }, { id: 'unsafe_object_assertion', - pattern: /unused_ast_detected/, + pattern: /(?!)/, 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/, + pattern: /(?!)/, message: "Found unsafe array type assertion. Use proper type guards or runtime validation.", severity: 'high', description: 'Detects as T[] or as Array (AST-based)' @@ -978,7 +1031,8 @@ class AISlopDetector { // 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') { + pattern.id === 'unsafe_array_assertion' || + pattern.id === 'unsafe_type_assertion') { continue; } @@ -1120,15 +1174,6 @@ class AISlopDetector { } } - // 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') { @@ -1822,8 +1867,6 @@ class AISlopDetector { } - - /** * Calculate comprehensive KarpeSlop score based on the three axes */ diff --git a/package-lock.json b/package-lock.json index bdecd1e..fc68a5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "karpeslop", - "version": "1.0.28", + "version": "1.0.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "karpeslop", - "version": "1.0.28", + "version": "1.0.29", "license": "MIT", "dependencies": { "glob": "13.0.6", diff --git a/package.json b/package.json index 7a29409..732b927 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "karpeslop", - "version": "1.0.28", + "version": "1.0.29", "description": "The linter Andrej Karpathy wishes existed. Detects the three axes of AI slop with extreme prejudice.", "type": "module", "packageManager": "npm@10.9.7", diff --git a/tests/ai-slop-detector.behavior.test.ts b/tests/ai-slop-detector.behavior.test.ts index 8cb3482..6c9d569 100644 --- a/tests/ai-slop-detector.behavior.test.ts +++ b/tests/ai-slop-detector.behavior.test.ts @@ -223,6 +223,97 @@ test('findUnsafeAssertions on the fixtures/unsafe-assertions.ts fixture reports assert.ok(objFindings[0].code.includes('Record')); }); +test('findUnsafeAssertions detects unsafe as any assertion', () => { + const code = 'const x = value as any;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 1); + assert.equal(findings[0].type, 'unsafe_type_assertion'); + assert.equal(findings[0].line, 1); + assert.equal(findings[0].severity, 'high'); + assert.ok(findings[0].code.includes('as any')); +}); + +test('findUnsafeAssertions does not report as any inside a line comment', () => { + const code = '// this is as any comment'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report as any inside a string', () => { + const code = 'const msg = "this is as any test";'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report as any inside a template literal', () => { + const code = 'const msg = `this is as any test`;'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions detects unsafe as any assertion in nested expression', () => { + const code = 'const x = foo(value as any);'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 1); + assert.equal(findings[0].type, 'unsafe_type_assertion'); + assert.equal(findings[0].line, 1); +}); + +test('findUnsafeAssertions detects unsafe as any assertion in multiline expression', () => { + const code = 'const x = value as\n any;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 1); + assert.equal(findings[0].type, 'unsafe_type_assertion'); + assert.equal(findings[0].line, 2); +}); + +test('findUnsafeAssertions suppresses multiline as any with eslint-disable-line on the any line', () => { + const code = 'const x = value as\nany; // eslint-disable-line'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('unsafe_type_assertion guidance does not recommend chained assertion pattern to users', () => { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', path.resolve(process.cwd(), 'ai-slop-detector.ts'), path.resolve(process.cwd(), 'tests/t4-educational-output.ts')], + { encoding: 'utf-8', cwd: process.cwd() } + ); + + assert.ok(!result.stdout.includes('as unknown as TargetType'), + `guidance should not recommend chained assertions. stdout:"${result.stdout}"`); +}); + +test('findUnsafeAssertions detects multiple as any assertions on separate lines', () => { + const code = 'const a = x as any;\nconst b = y as any;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 2); + assert.ok(findings.every(f => f.type === 'unsafe_type_assertion')); +}); + +test('findUnsafeAssertions does not report as any when preceded by @ts-expect-error', () => { + const code = '// @ts-expect-error\nconst x = value as any;'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report as any when preceded by eslint-disable-next-line', () => { + const code = '// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst x = value as any;'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + +test('findUnsafeAssertions does not report as any in .d.ts files', () => { + assert.equal(findUnsafeAssertions('const x = value as any;', 'types.d.ts').length, 0); +}); + +test('findUnsafeAssertions suppresses as any on line 1 with eslint-disable-line', () => { + const code = 'const x = value as any; // eslint-disable-line'; + assert.equal(findUnsafeAssertions(code, 'x.ts').length, 0); +}); + test('findUnsafeAssertions detects chained as unknown as T as unsafe_double_type_assertion', () => { const code = 'const rows = value as unknown as EventRow[];'; @@ -236,6 +327,131 @@ test('findUnsafeAssertions detects chained as unknown as T as unsafe_double_type assert.ok(finding.code.includes('as unknown as')); }); +test('findUnsafeAssertions detects chained as Foo as Bar as unsafe_double_type_assertion', () => { + const code = 'const value = input as Foo as Bar;'; + + 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'); +}); + +test('findUnsafeAssertions reports 2 findings for 3-deep chain', () => { + const code = 'const value = input as Bar as Baz as Qux;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 2); + assert.ok(findings.every(f => f.type === 'unsafe_double_type_assertion')); +}); + +test('findUnsafeAssertions reports 3 findings for 4-deep chain', () => { + const code = 'const value = input as Bar as Baz as Qux as Zap;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 3); + assert.ok(findings.every(f => f.type === 'unsafe_double_type_assertion')); +}); + +test('findUnsafeAssertions reports double and any for chained as Bar as any', () => { + const code = 'const value = input as Bar as any;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 2); + const types = findings.map(f => f.type); + assert.ok(types.includes('unsafe_double_type_assertion')); + assert.ok(types.includes('unsafe_type_assertion')); + + const anyFinding = findings.find(f => f.type === 'unsafe_type_assertion')!; + assert.ok(anyFinding.code.includes('as any')); +}); + +test('findUnsafeAssertions reports both double and any assertion for chained as unknown as any', () => { + const code = 'const value = input as unknown as any;'; + + const findings = findUnsafeAssertions(code, 'x.ts'); + + assert.equal(findings.length, 2); + const types = findings.map(f => f.type); + assert.ok(types.includes('unsafe_double_type_assertion'), 'should report double assertion'); + assert.ok(types.includes('unsafe_type_assertion'), 'should report any assertion'); + assert.ok(findings.every(f => f.severity === 'high')); +}); + +test('severityOverrides disabling unsafe_double_type_assertion still reports unsafe_type_assertion for chained as unknown as any', () => { + const fixtureFile = path.resolve(process.cwd(), 'tests/fixtures/temp-chained-any-fixture.ts'); + const configFile = path.resolve(process.cwd(), '.karpesloprc.json'); + const savedConfig = fs.readFileSync(configFile, 'utf-8'); + + fs.writeFileSync(fixtureFile, 'const value = input as unknown as any;\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, 1, `Expected exit code 1 (high severity finding) but got ${result.status}. stdout:"${result.stdout}" stderr:"${result.stderr}"`); + assert.ok(!result.stdout.includes('unsafe_double'), 'should not mention the suppressed double assertion'); + assert.ok(result.stdout.includes('unsafe_type_assertion'), 'should still mention the any assertion'); + } finally { + fs.unlinkSync(fixtureFile); + fs.writeFileSync(configFile, savedConfig, 'utf-8'); + } +}); + +test('default mode skips unsafe_type_assertion in test files', () => { + const tmpDir = fs.mkdtempSync(path.join(process.platform === 'win32' ? process.env.TEMP! : '/tmp', 'karpeslop-spec-')); + const fixtureFile = path.join(tmpDir, 'component.test.ts'); + + fs.writeFileSync(fixtureFile, 'const x = value as any;\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.ok(!result.stdout.includes('unsafe_type_assertion'), + `should not report as any in test files. stdout:"${result.stdout}"`); + } finally { + fs.unlinkSync(fixtureFile); + fs.rmdirSync(tmpDir); + } +}); + +test('default mode still reports unsafe_type_assertion in non-test files', () => { + const tmpDir = fs.mkdtempSync(path.join(process.platform === 'win32' ? process.env.TEMP! : '/tmp', 'karpeslop-src-')); + const fixtureFile = path.join(tmpDir, 'component.ts'); + + fs.writeFileSync(fixtureFile, 'const x = value as any;\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.ok(result.stdout.includes('unsafe_type_assertion'), + `should report as any in non-test files. stdout:"${result.stdout}"`); + } 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'); diff --git a/tests/t4-educational-output.ts b/tests/t4-educational-output.ts index 5d23fa4..4a7dd51 100644 --- a/tests/t4-educational-output.ts +++ b/tests/t4-educational-output.ts @@ -6,7 +6,7 @@ // Should show: Fix: Replace with 'unknown' and use type guards const data: any = {}; -// Should show: Fix: Use 'as unknown as TargetType' +// Should show: Fix: Prefer migrating the source/data contract first const value = something as any; // Should show: Fix: Actually implement the logic