Files
harness-tools/extensions/native-hooks/src/analyzers/ast-grep.ts
T

45 lines
5.1 KiB
TypeScript

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<AnalyzerFailure | undefined>;
constructor(private readonly exec: Exec, private readonly root: string, private readonly configPath: string, private readonly executable = "ast-grep") {}
prepare(signal?: AbortSignal): Promise<AnalyzerFailure | undefined> { return this.versionChecked ??= this.checkVersion(signal); }
async analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise<AnalysisSnapshot> {
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<AnalyzerFailure | undefined> {
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<string, unknown>;
const range = row.range as Record<string, unknown>; const start = range?.start as Record<string, unknown>; const end = range?.end as Record<string, unknown>;
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) }); }