/** * ast-grep CLI invocation helpers. * * The extension shells out to the ast-grep binary. The binary is resolved * from the AST_GREP_BIN environment variable when set, otherwise from PATH. * All output is bounded to keep tool results inside Pi's fixed limits. */ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead, withFileMutationQueue, } from "@earendil-works/pi-coding-agent"; /** Pi's fixed tool output limit (bytes), imported so it cannot desync. */ export const DEFAULT_OUTPUT_BYTES = DEFAULT_MAX_BYTES; /** Pi's fixed tool output limit (lines), imported so it cannot desync. */ export const DEFAULT_OUTPUT_LINES = DEFAULT_MAX_LINES; /** Default per-tool timeout for ast-grep invocations. */ export const DEFAULT_TIMEOUT_MS = 120_000; /** Oldest ast-grep version the extension supports (outline requires 0.44+). */ export const AST_GREP_VERSION_FLOOR = { major: 0, minor: 44, patch: 0 } as const; /** Spill directories created for truncated output, removed on shutdown. */ const spillDirs = new Set(); /** Matches the first `major.minor.patch` triplet in a version line. */ const VERSION_TRIPLET = /(\d+)\.(\d+)\.(\d+)/; /** Strips the `ast-grep ` name prefix from a --version line. */ const VERSION_NAME_PREFIX = /^ast-grep\s+/; /** Parses the first `major.minor.patch` triplet found in a version line. */ export function parseAstGrepVersion(line: string): { major: number; minor: number; patch: number } | undefined { const groups = VERSION_TRIPLET.exec(line); if (groups === null) { return; } const major = Number(groups[1]); const minor = Number(groups[2]); const patch = Number(groups[3]); if (!(Number.isInteger(major) && Number.isInteger(minor) && Number.isInteger(patch))) { return; } return { major, minor, patch }; } export interface ExecOutcome { code: number; killed: boolean; stderr: string; stdout: string; } export interface ExecOptions { cwd?: string; signal?: AbortSignal; timeoutMs?: number; } /** Runs one binary invocation and resolves with its captured output. */ export type CliRunner = (binary: string, args: string[], options: ExecOptions) => Promise; /** Binds a CliRunner to pi.exec, which enforces Pi's own process limits. */ export function piExecRunner(pi: ExtensionAPI): CliRunner { return (binary, args, options) => pi.exec(binary, args, { ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.cwd === undefined ? {} : { cwd: options.cwd }), ...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }), }); } /** Resolves the ast-grep binary path, honoring AST_GREP_BIN. */ export function resolveBinary(): string { const configured = process.env.AST_GREP_BIN?.trim(); return configured === undefined || configured === "" ? "ast-grep" : configured; } export interface BoundedText { text: string; truncated: boolean; } interface BoundedOutput extends BoundedText { truncation?: TruncationResult; } /** Slices a string to fit within a UTF-8 byte budget without splitting code points. */ function utf8SliceToBytes(text: string, maxBytes: number): string { if (Buffer.byteLength(text, "utf8") <= maxBytes) { return text; } let low = 0; let high = text.length; while (low < high) { const mid = Math.floor((low + high + 1) / 2); if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) { low = mid; } else { high = mid - 1; } } // Never leave a lone high surrogate at the cut; it would re-encode as // U+FFFD and split a surrogate pair. if (low < text.length) { const code = text.charCodeAt(low - 1); if (code >= 0xd8_00 && code <= 0xdb_ff) { low -= 1; } } return text.slice(0, low); } function truncationMarker(result: TruncationResult): string { if (result.truncatedBy === "lines") { return `\n…[truncated after ${result.outputLines} lines (of ${result.totalLines})]`; } return `\n…[truncated: showing ${formatSize(result.outputBytes)} of ${formatSize(result.totalBytes)}]`; } function boundOutputWithDetails(stdout: string, maxBytes: number, maxLines: number): BoundedOutput { const result = truncateHead(stdout, { maxBytes, maxLines }); if (!result.truncated) { return { text: stdout, truncated: false }; } const marker = truncationMarker(result); // truncateHead keeps nothing when the first line alone exceeds the byte // limit; keep a byte-safe prefix of that line so truncated JSON output // stays recoverable by parseJsonArray. const kept = result.firstLineExceedsLimit ? (stdout.split("\n", 1)[0] ?? "") : result.content; const content = utf8SliceToBytes(kept, Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"))); return { text: `${content}${marker}`, truncated: true, truncation: result }; } /** * Cuts output to the fixed byte and line limits. * * Delegates to pi's truncateHead, which never returns partial lines. When * the first line alone exceeds the byte limit a byte-safe prefix of that * line is kept so truncated JSON output stays parseable. */ export function boundOutput( stdout: string, maxBytes = DEFAULT_OUTPUT_BYTES, maxLines = DEFAULT_OUTPUT_LINES, ): BoundedText { return boundOutputWithDetails(stdout, maxBytes, maxLines); } export interface AstGrepResult extends BoundedText { code: number; /** Temp file holding the full untruncated stdout when the output was cut. */ fullOutputPath?: string; stderr: string; /** Truncation details when the output was cut. */ truncation?: TruncationResult; } export interface RunOptions extends ExecOptions { /** Exit codes that count as a successful invocation. Defaults to [0]. */ allowCodes?: readonly number[]; /** * Accept empty stdout+stderr on an allowed exit code. Used by the rewrite * apply pass: a zero-match `--update-all` prints nothing and exits 1, which * is a legitimate idempotent re-apply, not a broken spawn. */ allowEmptyOutput?: boolean; /** Path that must exist before the binary is spawned. */ checkTarget?: string; } /** Surfaces non-empty stderr as an error text when a run found no matches. */ export function surfacedStderr(stderr: string, hasMatches: boolean): string | undefined { if (hasMatches || stderr.trim() === "") { return; } return stderr.trim().slice(0, 500); } function errorText(outcome: ExecOutcome): string { const stderr = outcome.stderr.trim(); if (stderr !== "") { return stderr; } const stdout = outcome.stdout.trim(); return stdout === "" ? `exit code ${outcome.code}` : stdout.slice(0, 500); } function missingBinaryMessage(binary: string, error: unknown): string { const message = error instanceof Error ? error.message : String(error); return ( `ast-grep could not be started (binary: ${binary}): ${message}. ` + "Install ast-grep (see https://astgrep.com) or point AST_GREP_BIN at the binary." ); } function isErrnoCode(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code; } /** * Runs the ast-grep binary with bounds and error mapping. * * ast-grep uses non-zero exit codes for successful-but-empty or * diagnostics-bearing runs, so callers must pass the codes they accept: * - `run` exits 0 with matches and 1 without matches (JSON still printed). * - `scan` exits 0 without error-severity matches and 1 when error-severity * diagnostics were found (JSON still printed). */ export async function runAstGrep( runner: CliRunner, args: readonly string[], options: RunOptions = {}, ): Promise { const binary = resolveBinary(); const allowCodes = new Set(options.allowCodes ?? [0]); if (options.checkTarget !== undefined) { try { await stat(options.checkTarget); } catch (error) { if (isErrnoCode(error, "ENOENT")) { throw new Error(`${options.checkTarget}: no such file or directory`, { cause: error }); } throw error; } } let outcome: ExecOutcome; try { outcome = await runner(binary, [...args], options); } catch (error) { throw new Error(missingBinaryMessage(binary, error), { cause: error }); } if (outcome.killed === true) { throw new Error("ast-grep invocation was cancelled or timed out"); } // Pi's exec resolves (never rejects) when the binary cannot be spawned, // returning empty output with a non-zero code. A legitimate run always // prints at least "[]" to stdout, so empty stdout AND empty stderr can // only mean the process never produced output. if (outcome.stdout.trim() === "" && outcome.stderr.trim() === "" && options.allowEmptyOutput !== true) { throw new Error(missingBinaryMessage(binary, new Error("the binary produced no output"))); } if (outcome.code !== 0 && !allowCodes.has(outcome.code)) { throw new Error(`ast-grep exited with code ${outcome.code}: ${errorText(outcome)}`); } const bounded = boundOutputWithDetails(outcome.stdout, DEFAULT_OUTPUT_BYTES, DEFAULT_OUTPUT_LINES); const result: AstGrepResult = { text: bounded.text, truncated: bounded.truncated, stderr: outcome.stderr, code: outcome.code, ...(bounded.truncation === undefined ? {} : { truncation: bounded.truncation }), }; if (bounded.truncation !== undefined) { const dir = await mkdtemp(join(tmpdir(), "pi-ast-grep-")); spillDirs.add(dir); const tempFile = join(dir, "full-output.json"); await withFileMutationQueue(tempFile, () => writeFile(tempFile, outcome.stdout, "utf8")); result.fullOutputPath = tempFile; } return result; } /** * Removes every registered spill directory created for truncated output. * * Called from the session shutdown hook so truncated-output temp files do * not accumulate across sessions. Errors removing a directory are swallowed * so one stale dir cannot block the rest. */ export async function cleanupSpillDirs(): Promise { // Removals are independent; parallelize, and swallow per-directory errors // so one stale dir cannot block the rest of shutdown. await Promise.all([...spillDirs].map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined))); spillDirs.clear(); } export interface BinaryCheck { binary: string; satisfiesFloor: boolean; version: string; } /** Verifies the ast-grep binary is reachable and returns its version. */ export async function checkBinary(runner: CliRunner): Promise { const binary = resolveBinary(); const result = await runAstGrep(runner, ["--version"], { allowCodes: [0] }); const version = (result.text.trim().split("\n")[0] ?? "unknown version").replace(VERSION_NAME_PREFIX, ""); const parsed = parseAstGrepVersion(version); const floor = AST_GREP_VERSION_FLOOR; const satisfiesFloor = parsed !== undefined && (parsed.major > floor.major || (parsed.major === floor.major && parsed.minor > floor.minor) || (parsed.major === floor.major && parsed.minor === floor.minor && parsed.patch >= floor.patch)); return { binary, version, satisfiesFloor }; }