import { createHash } from "node:crypto"; import { isAbsolute, relative, resolve } from "node:path"; import type { AnalyzerAdapter } from "../analysis.ts"; import type { AnalysisSnapshot, AnalyzerFailure, Finding, Severity } from "../policy.ts"; import type { Exec } from "../project-context.ts"; const MAX_PATHS = 200; const MAX_FINDINGS = 200; const MAX_STDOUT = 2_000_000; export class AstGrepAdapter implements AnalyzerAdapter { private versionChecked?: Promise; constructor(private readonly exec: Exec, private readonly root: string, private readonly configPath: string, private readonly executable = "ast-grep") {} prepare(signal?: AbortSignal): Promise { return this.versionChecked ??= this.checkVersion(signal); } async analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise { const started = performance.now(); const bounded = paths.slice(0, MAX_PATHS); const versionFailure = await this.prepare(signal); if (versionFailure) return failure("ast-grep:personal", workspaceRevision, bounded, started, versionFailure); const scanPaths = bounded.map((path) => isAbsolute(path) ? path : resolve(this.root, path)); const args = ["scan", "--config", this.configPath, "--json=stream", "--color=never", "--max-results", String(MAX_FINDINGS), "--", ...scanPaths]; const result = await this.exec(this.executable, args, { timeout: 20_000, ...(signal ? { signal } : {}) }); if (result.killed) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "killed", message: "ast-grep was killed or timed out" }); if (result.code !== 0) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "nonzero", message: `ast-grep exited ${result.code}: ${result.stderr.slice(0, 500)}` }); if (Buffer.byteLength(result.stdout) > MAX_STDOUT) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: "ast-grep output exceeded 2MB" }); try { const findings = result.stdout.split("\n").filter(Boolean).slice(0, MAX_FINDINGS).map((line) => normalize(JSON.parse(line), this.root)); return Object.freeze({ analyzerKey: "ast-grep:personal", workspaceRevision, paths: Object.freeze([...bounded]), findings: Object.freeze(findings), durationMs: Math.round(performance.now() - started) }); } catch (error) { return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: error instanceof Error ? error.message : String(error) }); } } private async checkVersion(signal?: AbortSignal): Promise { const result = await this.exec(this.executable, ["--version"], { timeout: 5000, ...(signal ? { signal } : {}) }); if (result.code !== 0) return { kind: "missing", message: `ast-grep is unavailable: ${result.stderr || result.stdout}` }; const match = result.stdout.match(/(\d+)\.(\d+)\.(\d+)/); if (!match || Number(match[1]) < 0 || Number(match[2]) < 45) return { kind: "incompatible", message: `ast-grep >=0.45 is required; got ${result.stdout.trim()}` }; return undefined; } } function normalize(value: unknown, root: string): Finding { if (!value || typeof value !== "object") throw new Error("finding is not an object"); const row = value as Record; const range = row.range as Record; const start = range?.start as Record; const end = range?.end as Record; const text = typeof row.text === "string" ? row.text : ""; const rawPath = requiredString(row.file, "file"); const severity = row.severity; if (!start || !end || !Number.isInteger(start.line) || !Number.isInteger(start.column) || !Number.isInteger(end.line) || !Number.isInteger(end.column)) throw new Error("finding has invalid range"); if (severity !== "hint" && severity !== "info" && severity !== "warning") throw new Error(`unsupported steering severity ${String(severity)}`); const path = isAbsolute(rawPath) ? relative(root, rawPath) : relative(root, resolve(root, rawPath)); return Object.freeze({ ruleId: requiredString(row.ruleId, "ruleId"), severity: severity as Severity, message: requiredString(row.message, "message"), ...(typeof row.note === "string" ? { note: row.note } : {}), path, range: Object.freeze({ start: Object.freeze({ line: start.line as number, column: start.column as number }), end: Object.freeze({ line: end.line as number, column: end.column as number }) }), matchedTextHash: createHash("sha256").update(text).digest("hex") }); } function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value) throw new Error(`finding has invalid ${name}`); return value; } function failure(key: string, revision: number, paths: readonly string[], started: number, analyzerFailure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision: revision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: Math.round(performance.now() - started), failure: Object.freeze(analyzerFailure) }); }