pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
517 lines (516 loc) • 21.7 kB
JavaScript
/**
* SgRunner - encapsulates ast-grep subprocess management
*
* Extracted from AstGrepClient to simplify the main client.
* Handles: spawn, spawnSync, temp dir management, JSON parsing.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { getSgCommand } from "./dispatch/runners/utils/runner-helpers.js";
import { getProjectIgnoreGlobs } from "./file-utils.js";
import { findGlobalBinary } from "./package-manager.js";
import { safeSpawnAsync } from "./safe-spawn.js";
/**
* Escape an argument for Windows cmd.exe shell execution.
* Handles spaces, quotes, and special characters.
*/
function escapeWindowsArg(arg) {
// If no special characters, return as-is
if (!/[\s"]/.test(arg))
return arg;
// Escape quotes by doubling them
return `"${arg.replace(/"/g, '""')}"`;
}
/**
* Build the `bash -c` argv that runs `cmd` with `allArgs` as POSITIONAL
* parameters. The script is the constant `"$0" "$@"`, so bash re-emits the
* command and every arg verbatim — no parameter expansion, no word-splitting.
*
* Two properties this guarantees, neither of which string-interpolation could:
* - ast-grep `$METAVAR` patterns reach the binary literally (not shell-expanded).
* - an environment-derived command path (PATH-resolved `ast-grep`/`sg`/`npx`)
* cannot inject shell — it's argv[0], never part of the script string
* (CodeQL js/shell-command-injection-from-environment).
*/
export function buildBashRunArgs(cmd, allArgs) {
return ["-c", '"$0" "$@"', cmd, ...allArgs];
}
function sgExcludeArgsForProject(rootDir) {
return getProjectIgnoreGlobs(rootDir).flatMap((glob) => [
"--globs",
`!${glob}`,
]);
}
/**
* Format metavariable captures for display below a match line.
* Single captures: $VAR=x $NAME=foo
* Multi captures: $$$ARGS=a,b,c
* Returns undefined when there are no meaningful captures.
*/
function formatMetaVarCaptures(mv) {
if (!mv)
return undefined;
const parts = [];
for (const [name, node] of Object.entries(mv.single)) {
if (node.text)
parts.push(`$${name}=${node.text}`);
}
for (const [name, nodes] of Object.entries(mv.multi)) {
if (nodes.length > 0) {
const joined = nodes.map((n) => n.text).join("");
if (joined)
parts.push(`$$$${name}=${joined}`);
}
}
for (const [name, value] of Object.entries(mv.transformed)) {
if (value)
parts.push(`@${name}=${value}`);
}
if (parts.length === 0)
return undefined;
return ` ${parts.join(" ")}`;
}
export class SgRunner {
log;
sgPath = null;
sgArgsPrefix = [];
available = null;
ensureInFlight = null;
constructor(verbose = false) {
this.log = verbose
? (msg) => console.error(`[sg-runner] ${msg}`)
: () => { };
}
/**
* Check if ast-grep CLI is available, auto-install if not.
*
* Re-entrancy safe: concurrent first-time callers share a single
* `ensureInFlight` promise so probing/auto-install isn't duplicated
* across session-start tasks. Mirrors the dedupe pattern in
* `KnipClient.ensureAvailable` and `DependencyChecker.ensureAvailable`.
*/
async ensureAvailable() {
// Fast path: already checked.
if (this.available !== null)
return this.available;
if (this.ensureInFlight)
return this.ensureInFlight;
this.ensureInFlight = this.doEnsureAvailable();
try {
return await this.ensureInFlight;
}
finally {
this.ensureInFlight = null;
}
}
async doEnsureAvailable() {
// Step 1: PATH — canonical binary names + npx fallback.
// Prefer ast-grep over sg on Linux: /usr/bin/sg is util-linux, not ast-grep.
const pathCommand = await this.probeCommandCandidates([
{ cmd: "ast-grep", argsPrefix: [] },
{ cmd: "sg", argsPrefix: [] },
{ cmd: "npx", argsPrefix: ["--no", "--", "ast-grep"] },
]);
if (pathCommand) {
this.sgPath = pathCommand.cmd;
this.sgArgsPrefix = pathCommand.argsPrefix;
this.available = true;
this.log(`ast-grep found on PATH: ${pathCommand.cmd}`);
return true;
}
// Step 1b: any package manager's global bin dir (npm/pnpm/yarn/bun).
// Catches a `pnpm add -g @ast-grep/cli` shim whose bin dir is off PATH (so
// Step 1 misses) and which is a global — not a local node_modules platform
// package — so Step 2 misses it too (#375).
for (const name of ["ast-grep", "sg"]) {
const globalBin = await findGlobalBinary(name);
if (globalBin && (await this.probeCommand(globalBin, []))) {
this.sgPath = globalBin;
this.sgArgsPrefix = [];
this.available = true;
this.log(`ast-grep found in global bin: ${globalBin}`);
return true;
}
}
// Step 2: platform-specific npm package binaries.
// Covers setups where @ast-grep/cli-{os}-{arch} is installed but the binary
// directory is not on PATH (common with pnpm, Yarn PnP, or isolated installs).
const platformBinary = await this.probePlatformPackageBinary();
if (platformBinary) {
this.sgPath = platformBinary;
this.sgArgsPrefix = [];
this.available = true;
this.log(`ast-grep found via platform package: ${platformBinary}`);
return true;
}
// Step 3: Homebrew (macOS only).
if (process.platform === "darwin") {
const brewBinary = await this.probeHomebrew();
if (brewBinary) {
this.sgPath = brewBinary;
this.sgArgsPrefix = [];
this.available = true;
this.log(`ast-grep found via Homebrew: ${brewBinary}`);
return true;
}
}
// Step 4: auto-install via pi-lens installer.
this.log("ast-grep not found, attempting auto-install...");
const { ensureTool } = await import("./installer/index.js");
const installedPath = await ensureTool("ast-grep");
if (installedPath && (await this.probeCommand(installedPath, []))) {
this.sgPath = installedPath;
this.sgArgsPrefix = [];
this.available = true;
this.log(`ast-grep auto-installed: ${installedPath}`);
return true;
}
this.available = false;
return false;
}
/**
* Probe platform-specific @ast-grep/cli-{os}-{arch} npm packages.
* These ship the binary at the package root (sg / sg.exe).
*/
async probePlatformPackageBinary() {
const { platform, arch } = process;
const exeName = platform === "win32" ? "sg.exe" : "sg";
// Map Node.js platform/arch to @ast-grep/cli package suffix.
const pkgSuffixes = [];
if (platform === "linux" && arch === "x64")
pkgSuffixes.push("linux-x64-gnu");
if (platform === "linux" && arch === "arm64")
pkgSuffixes.push("linux-arm64-gnu");
if (platform === "darwin" && arch === "arm64")
pkgSuffixes.push("darwin-arm64");
if (platform === "darwin" && arch === "x64")
pkgSuffixes.push("darwin-x64");
if (platform === "win32" && arch === "x64")
pkgSuffixes.push("win32-x64-msvc");
if (platform === "win32" && arch === "arm64")
pkgSuffixes.push("win32-arm64-msvc");
// Search roots: local node_modules and any parent node_modules directories.
const searchRoots = [];
let dir = process.cwd();
for (let depth = 0; depth < 5; depth++) {
searchRoots.push(path.join(dir, "node_modules"));
const parent = path.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
for (const suffix of pkgSuffixes) {
const pkgName = `@ast-grep/cli-${suffix}`;
for (const root of searchRoots) {
const candidate = path.join(root, pkgName, exeName);
try {
if (fs.existsSync(candidate) && (await this.probeCommand(candidate, []))) {
return candidate;
}
}
catch {
// not found or not executable — try next
}
}
}
return undefined;
}
/**
* Probe Homebrew installation (macOS only).
* Runs `brew --prefix ast-grep` and checks the resulting bin directory.
*/
async probeHomebrew() {
try {
const result = await safeSpawnAsync("brew", ["--prefix", "ast-grep"], {
timeout: 3000,
});
if (result.error || result.status !== 0)
return undefined;
const prefix = result.stdout.trim();
if (!prefix)
return undefined;
for (const name of ["ast-grep", "sg"]) {
const candidate = path.join(prefix, "bin", name);
if (fs.existsSync(candidate) && (await this.probeCommand(candidate, []))) {
return candidate;
}
}
}
catch {
// brew not installed or timed out
}
return undefined;
}
isAstGrepVersionOutput(output) {
return /\bast[- ]grep\b/i.test(output);
}
async probeCommand(cmd, argsPrefix) {
const result = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
timeout: 5000,
});
return (!result.error &&
result.status === 0 &&
this.isAstGrepVersionOutput(`${result.stdout}\n${result.stderr}`));
}
async probeCommandCandidates(candidates) {
for (const candidate of candidates) {
if (await this.probeCommand(candidate.cmd, candidate.argsPrefix)) {
return candidate;
}
}
return undefined;
}
/**
* Get the ast-grep command to use, plus any npx prefix arguments.
*/
getSgCommand() {
return {
cmd: this.sgPath || "ast-grep",
argsPrefix: this.sgArgsPrefix,
};
}
async execRaw(args, timeout = 30000) {
const command = this.getSgCommand();
const result = await safeSpawnAsync(command.cmd, [...command.argsPrefix, ...args], { timeout });
return {
stdout: result.stdout,
stderr: result.stderr,
status: result.status,
error: result.error?.message,
};
}
/**
* Run ast-grep asynchronously, return parsed matches
*/
async exec(args) {
return new Promise((resolve) => {
const command = this.getSgCommand();
const allArgs = [...command.argsPrefix, ...args];
// On Windows with Git Bash/MSYS2, we need to use bash to properly
// handle $variables in patterns (prevent shell expansion)
const isWindows = process.platform === "win32";
const hasBash = process.env.MSYSTEM || process.env.GIT_SHELL;
let proc;
if (isWindows && hasBash) {
// Run via bash (Git Bash/MSYS2) so $-metavariables in ast-grep
// patterns aren't shell-expanded. Pass the command + args as
// POSITIONAL parameters (`"$0"`/`"$@"`) instead of interpolating
// them into the -c string: bash re-emits `"$@"` verbatim — no
// parameter expansion, no word-splitting — so patterns stay literal
// AND an environment-derived command path cannot inject shell
// (fixes CodeQL js/shell-command-injection-from-environment). This
// also removes the brittle hand-rolled quoting it replaced.
proc = spawn("bash", buildBashRunArgs(command.cmd, allArgs), {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
}
else if (isWindows) {
// Fallback: shell:true needed for npm-installed .cmd wrappers on Windows.
// Pass cmd and args separately — do not concatenate into one string.
proc = spawn(command.cmd, allArgs.map(escapeWindowsArg), {
stdio: ["ignore", "pipe", "pipe"],
shell: true,
windowsHide: true,
});
}
else {
// Unix: normal spawn without shell
proc = spawn(command.cmd, allArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
}
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => (stdout += data.toString()));
proc.stderr.on("data", (data) => (stderr += data.toString()));
const empty = () => ({
matches: [],
totalMatches: 0,
truncated: false,
});
proc.on("error", (err) => {
if (err.message.includes("ENOENT")) {
resolve({
...empty(),
error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
});
}
else {
resolve({ ...empty(), error: err.message });
}
});
proc.on("close", (code) => {
if (code !== 0 && !stdout.trim()) {
const stderrMsg = stderr.trim();
if (code === 1 && !stderrMsg) {
resolve(empty());
return;
}
if (stderrMsg.includes("Multiple AST nodes are detected")) {
resolve({
...empty(),
error: `Invalid AST pattern: The pattern appears to contain multiple AST nodes or is malformed.\n` +
`Common causes:\n` +
` 1. Missing parentheses: use it($TEST) not it"test"\n` +
` 2. Raw text without structure: use console.log($MSG) not just "console.log"\n` +
` 3. Unclosed quotes or brackets\n\n` +
`Original error: ${stderrMsg}`,
});
return;
}
if (stderrMsg.includes("Cannot parse query")) {
resolve({
...empty(),
error: `Pattern syntax error: The pattern could not be parsed as valid code.\n` +
`Tips:\n` +
` - Patterns must be valid ${args.includes("--lang") ? args[args.indexOf("--lang") + 1] : "language"} syntax\n` +
` - Use metavariables like $NAME, $ARGS for variable parts\n` +
` - Example: 'function $NAME($$$PARAMS) { $$$BODY }'\n\n` +
`Original error: ${stderrMsg}`,
});
return;
}
resolve({
...empty(),
error: stderrMsg || `Command failed with exit code ${code}`,
});
return;
}
if (!stdout.trim()) {
resolve(empty());
return;
}
try {
const parsed = JSON.parse(stdout);
const matches = Array.isArray(parsed) ? parsed : [parsed];
resolve({
matches,
totalMatches: matches.length,
truncated: false,
});
}
catch {
resolve({ ...empty(), error: "Failed to parse output" });
}
});
});
}
// --- Shared helpers for temp-dir rule scans ---
prepareTempScan(ruleId, ruleYaml) {
const sessionDir = path.join(os.tmpdir(), `pi-lens-temp-${ruleId}-${Date.now()}`);
const rulesSubdir = path.join(sessionDir, "rules");
const configFile = path.join(sessionDir, ".sgconfig.yml");
fs.mkdirSync(rulesSubdir, { recursive: true });
fs.writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
fs.writeFileSync(path.join(rulesSubdir, `${ruleId}.yml`), ruleYaml);
return { sessionDir, configFile };
}
parseScanOutput(output) {
if (!output.trim())
return [];
const items = JSON.parse(output);
return Array.isArray(items) ? items : [items];
}
cleanupTempScan(sessionDir) {
try {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
catch (err) {
this.log(`Cleanup failed: ${err.message}`);
}
}
async tempScanAsync(dir, ruleId, ruleYaml, timeout = 30000) {
const { sessionDir, configFile } = this.prepareTempScan(ruleId, ruleYaml);
try {
const { cmd: sgCmd, args: sgPre } = getSgCommand();
const result = await safeSpawnAsync(sgCmd, [
...sgPre,
"scan",
"--config",
configFile,
"--json",
...sgExcludeArgsForProject(dir),
dir,
], { timeout });
return this.parseScanOutput(result.stdout || result.stderr || "");
}
catch {
return [];
}
finally {
this.cleanupTempScan(sessionDir);
}
}
/**
* Run a rule scan with optional fix application.
* Dry-run: --json (returns matches for preview).
* Apply: --update-all (writes fixes defined in the YAML `fix:` field).
*/
async tempScanWithFixAsync(dir, ruleId, ruleYaml, applyFixes, timeout = 30000) {
const { sessionDir, configFile } = this.prepareTempScan(ruleId, ruleYaml);
try {
const { cmd: sgCmd, args: sgPre } = getSgCommand();
if (!applyFixes) {
const result = await safeSpawnAsync(sgCmd, [...sgPre, "scan", "--config", configFile, "--json",
...sgExcludeArgsForProject(dir), dir], { timeout });
return { matches: this.parseScanOutput(result.stdout || result.stderr || "") };
}
// Apply: capture matches BEFORE writing — once --update-all applies
// the fix the rule no longer matches, so a post-apply json pass would
// report zero even on a successful apply. Count first, then write.
const jsonResult = await safeSpawnAsync(sgCmd, [...sgPre, "scan", "--config", configFile, "--json",
...sgExcludeArgsForProject(dir), dir], { timeout });
const matches = this.parseScanOutput(jsonResult.stdout || jsonResult.stderr || "");
const applyResult = await safeSpawnAsync(sgCmd, [...sgPre, "scan", "--config", configFile, "--update-all",
...sgExcludeArgsForProject(dir), dir], { timeout });
if (applyResult.error) {
return { matches: [], error: applyResult.error.message };
}
return { matches };
}
catch (err) {
return { matches: [], error: String(err) };
}
finally {
this.cleanupTempScan(sessionDir);
}
}
/**
* Format matches for display
*/
formatMatches(matches, isDryRun = false, maxItems = 50, showModeIndicator = false) {
if (matches.length === 0) {
if (showModeIndicator) {
return isDryRun
? "[DRY-RUN] No matches found."
: "[NOT APPLIED] No matches found — nothing was changed. Run ast_grep_search to confirm the pattern matches before applying.";
}
return "No matches found";
}
const shown = matches.slice(0, maxItems);
const lines = shown.map((m) => {
const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
const langSuffix = m.language ? ` [${m.language}]` : "";
const base = isDryRun && m.replacement
? `${loc}\n - ${text}\n + ${m.replacement}`
: `${loc}: ${text}${langSuffix}`;
const captures = formatMetaVarCaptures(m.metaVariables);
return captures ? `${base}\n${captures}` : base;
});
if (matches.length > maxItems) {
lines.unshift(`Found ${matches.length} matches (showing first ${maxItems}):`);
}
if (showModeIndicator) {
const prefix = isDryRun ? "[DRY-RUN]" : "[APPLIED]";
const suffix = isDryRun
? "\n\n(Dry run — use apply=true to apply changes)"
: "";
return `${prefix} ${matches.length} replacement(s):\n\n${lines.join("\n")}${suffix}`;
}
return lines.join("\n");
}
}