import type { Issue } from "../types"; import { resolve, join } from "path"; import { readdirSync } from "fs"; import { loadIgnorePatterns, isFileIgnored } from "../ignore"; import { resolveToolCommand } from "../tool-command"; import { externalSuccess, externalWarning, type ExternalAnalyzerResult } from "./external-result"; const RULES_DIR = resolve(import.meta.dir, "../../src/rules"); const RULE_FILES = readdirSync(RULES_DIR) .filter((file) => file.endsWith(".yml") && file !== "sgconfig.yml") .map((file) => join(RULES_DIR, file)); interface AstGrepOptions { ruleFilter?: (ruleId: string) => boolean; fileFilter?: (filePath: string) => boolean; } export async function runAstGrep(targetPath: string, options: AstGrepOptions = {}): Promise { const sgCmd = Bun.which("sg") ? "sg" : resolveToolCommand(targetPath, "ast-grep"); const ignorePatterns = await loadIgnorePatterns(targetPath); const issues: Issue[] = []; const warnings: string[] = []; for (const ruleFile of RULE_FILES) { const result = Bun.spawnSync( [sgCmd, "scan", "--rule", ruleFile, targetPath, "--json=stream"], { stdout: "pipe", stderr: "pipe", timeout: 60_000 } ); if (result.exitCode !== 0 && result.exitCode !== 1) { const stderr = result.stderr.toString(); if (!stderr.includes("Cannot parse rule")) { const warning = externalWarning("ast-grep", `command exited with code ${result.exitCode ?? 1} for ${ruleFile}`, stderr).warning; if (warning) warnings.push(warning); } continue; } const stdout = result.stdout.toString().trim(); if (!stdout) continue; for (const line of stdout.split("\n")) { if (!line.trim()) continue; try { const match = JSON.parse(line); const ruleId: string = match.ruleId ?? match.rule_id ?? "UNKNOWN"; const meta = RULE_META[ruleId]; if (!meta) continue; const file = match.file ?? match.path ?? ""; if (options.ruleFilter && !options.ruleFilter(ruleId)) continue; if (options.fileFilter && !options.fileFilter(file)) continue; if (isFileIgnored(file, targetPath, ignorePatterns)) continue; issues.push({ id: ruleId, category: meta.category, severity: meta.severity, tier: meta.tier, file, line: match.range?.start?.line ?? match.start?.line ?? 0, column: match.range?.start?.column ?? match.start?.column, endLine: match.range?.end?.line ?? match.end?.line, endColumn: match.range?.end?.column ?? match.end?.column, message: match.message ?? meta.message, fix: meta.fix, tool: "ast-grep", }); } catch { // Non-JSON line from ast-grep, skip } } } return warnings.length > 0 ? { issues, warning: warnings.join("\n") } : externalSuccess(issues); } interface RuleMeta { category: Issue["category"]; severity: Issue["severity"]; tier: Issue["tier"]; message: string; fix?: string; } const RULE_META: Record = { EMPTY_CATCH: { category: "defensive-programming", severity: "HIGH", tier: 2, message: "Empty catch block swallows errors silently", fix: "Remove try-catch or add meaningful error handling", }, CATCH_RETURN_DEFAULT: { category: "defensive-programming", severity: "HIGH", tier: 2, message: "Catch block returns default value, hiding errors", fix: "Let the error propagate or handle it meaningfully", }, CATCH_LOG_CONTINUE: { category: "defensive-programming", severity: "MEDIUM", tier: 2, message: "Catch block logs and continues — error is swallowed", fix: "Re-throw after logging, or handle the error properly", }, ANY_TYPE: { category: "weak-types", severity: "HIGH", tier: 3, message: "Explicit 'any' type bypasses type safety", fix: "Replace with the correct specific type", }, AS_ANY_CAST: { category: "weak-types", severity: "CRITICAL", tier: 3, message: "'as any' cast hides type mismatch", fix: "Fix the underlying type mismatch instead of casting", }, OBJECT_TYPE: { category: "weak-types", severity: "MEDIUM", tier: 3, message: "'object' type is too broad", fix: "Use a specific interface or Record type", }, FUNCTION_TYPE: { category: "weak-types", severity: "MEDIUM", tier: 3, message: "'Function' type is untyped — use a specific signature", fix: "Replace with typed function signature: (args: T) => R", }, BANNER_COMMENT: { category: "ai-slop", severity: "LOW", tier: 1, message: "ASCII banner separator comment — visual noise", fix: "Remove the banner comment", }, CONSOLE_LOG: { category: "ai-slop", severity: "MEDIUM", tier: 1, message: "console.log left in production code", fix: "Remove or replace with proper logging", }, NOOP_CALLBACK: { category: "defensive-programming", severity: "MEDIUM", tier: 2, message: "No-op callback fallback — hides missing handler", fix: "Make the callback required or handle absence explicitly", }, DEEP_OPTIONAL_CHAIN: { category: "defensive-programming", severity: "MEDIUM", tier: 0, message: "Optional chain 3+ levels deep — data model may be wrong", }, LONG_FUNCTION: { category: "complexity", severity: "MEDIUM", tier: 0, message: "Function exceeds 50 lines", }, DEEP_NESTING: { category: "complexity", severity: "HIGH", tier: 0, message: "Conditional nesting 3+ levels deep", fix: "Extract inner blocks or use early returns", }, TODO_REMOVE: { category: "legacy-code", severity: "LOW", tier: 1, message: "TODO/FIXME comment with removal instruction", fix: "Resolve the TODO or remove the dead note", }, DEPRECATED_ANNOTATION: { category: "legacy-code", severity: "MEDIUM", tier: 2, message: "@deprecated annotation — this code should be removed", fix: "Remove the deprecated code and update callers", }, BARE_EXCEPT: { category: "defensive-programming", severity: "HIGH", tier: 2, message: "Bare except catches everything including KeyboardInterrupt", fix: "Specify the exception type: except ValueError, except Exception", }, PASS_STUB: { category: "ai-slop", severity: "MEDIUM", tier: 0, message: "Pass-only function body — likely a stub never implemented", }, PRINT_STATEMENT: { category: "ai-slop", severity: "MEDIUM", tier: 1, message: "print() left in production code", fix: "Remove or replace with logging module", }, UNWRAP_CALL: { category: "defensive-programming", severity: "MEDIUM", tier: 0, message: ".unwrap() can panic at runtime", fix: "Use ? operator or match/if-let for error handling", }, EXPECT_CALL: { category: "defensive-programming", severity: "LOW", tier: 0, message: ".expect() can panic — prefer proper error handling", }, TODO_MACRO: { category: "legacy-code", severity: "HIGH", tier: 0, message: "todo!()/unimplemented!() macro left in code", fix: "Implement the missing logic or remove", }, MANY_PARAMS: { category: "complexity", severity: "MEDIUM", tier: 0, message: "Function with 5+ parameters — consider an options object", }, DEAD_VARIABLE: { category: "dead-code", severity: "MEDIUM", tier: 2, message: "Variable declared but possibly unused", fix: "Remove if unused, or verify it's needed", }, BROAD_EXCEPT: { category: "defensive-programming", severity: "MEDIUM", tier: 0, message: "Broad 'except Exception' — catch specific exception types", }, STAR_IMPORT: { category: "inconsistency", severity: "MEDIUM", tier: 0, message: "Star import (from X import *) — import specific names", }, };