Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`.
- **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
Expand Down
228 changes: 192 additions & 36 deletions ai-slop-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -69,7 +81,7 @@ interface CustomPatternConfig {
interface KarpeSlopConfig {
customPatterns?: CustomPatternConfig[];
ignorePaths?: string[];
severityOverrides?: Record<string, 'critical' | 'high' | 'medium' | 'low'>;
severityOverrides?: Record<string, 'critical' | 'high' | 'medium' | 'low' | 'off'>;
blockOnCritical?: boolean;
minPackageAgeDays?: number;
}
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<T> (AST-based)'
}
];

Expand Down Expand Up @@ -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<string, unknown>;

// Validate customPatterns
Expand All @@ -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 {
Expand All @@ -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<string, unknown>)) {
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(', ')}`);
}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Comment on lines +1176 to +1180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Custom patterns can now use these IDs, but the built-in placeholder patterns are selected first and the pattern loop unconditionally skips every matching ID. Consequently, a custom pattern named unsafe_array_assertion, unsafe_object_assertion, or unsafe_double_type_assertion is silently ignored, and its configured message and severity cannot take effect. Reject these reserved IDs or distinguish custom patterns from the AST-only built-ins. [api mismatch]

Severity Level: Major ⚠️
- ❌ Custom rules using reserved IDs are silently ignored.
- ⚠️ Configured messages and severities become ineffective.
- ⚠️ AST findings use unintended built-in metadata.
Steps of Reproduction ✅
1. Configure `.karpesloprc.json` with a custom pattern whose ID is
`unsafe_array_assertion`, using a custom message and severity.

2. `AISlopDetector.loadConfig()` accepts that ID during validation at
`ai-slop-detector.ts:546-572`, appends the custom pattern at
`ai-slop-detector.ts:629-641`, and does not reject reserved IDs.

3. During scanning, `analyzeFile()` unconditionally skips every pattern with that ID at
`ai-slop-detector.ts:967-972`, so the custom regex is never evaluated.

4. For an input such as `const rows = data as EventRow[];`, `findUnsafeAssertions()`
creates an AST finding at `ai-slop-detector.ts:245-257`.

5. The lookup at `ai-slop-detector.ts:1164` returns the earlier built-in placeholder
pattern rather than the appended custom pattern, so the configured custom message and
severity are not applied.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** ai-slop-detector.ts
**Line:** 1164:1165
**Comment:**
	*Api Mismatch: Custom patterns can now use these IDs, but the built-in placeholder patterns are selected first and the pattern loop unconditionally skips every matching ID. Consequently, a custom pattern named `unsafe_array_assertion`, `unsafe_object_assertion`, or `unsafe_double_type_assertion` is silently ignored, and its configured message and severity cannot take effect. Reject these reserved IDs or distinguish custom patterns from the AST-only built-ins.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
11 changes: 5 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading