From a86cc6af18882042121ed1367f96b8c6c70500aa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 21:19:33 +0900 Subject: [PATCH] refactor(tools): remove native ast-grep tool Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/tool-registry.team-mode.test.ts | 2 - src/plugin/tool-registry.test.ts | 1 - src/plugin/tool-registry.ts | 4 - src/tools/ast-grep/AGENTS.md | 54 ---- .../ast-grep/cli-binary-path-resolution.ts | 60 ---- src/tools/ast-grep/cli.ts | 177 ----------- src/tools/ast-grep/constants.ts | 5 - src/tools/ast-grep/downloader.ts | 119 ------- src/tools/ast-grep/environment-check.ts | 89 ------ src/tools/ast-grep/index.ts | 5 - src/tools/ast-grep/language-support.ts | 63 ---- src/tools/ast-grep/pattern-hints.test.ts | 299 ------------------ src/tools/ast-grep/pattern-hints.ts | 63 ---- src/tools/ast-grep/process-output-timeout.ts | 28 -- src/tools/ast-grep/result-formatter.ts | 102 ------ src/tools/ast-grep/sg-cli-path.ts | 102 ------ src/tools/ast-grep/sg-compact-json-output.ts | 54 ---- src/tools/ast-grep/tool-descriptions.test.ts | 171 ---------- src/tools/ast-grep/tool-descriptions.ts | 35 -- src/tools/ast-grep/tools.test.ts | 55 ---- src/tools/ast-grep/tools.ts | 92 ------ src/tools/ast-grep/types.ts | 61 ---- src/tools/index.ts | 1 - 23 files changed, 1642 deletions(-) delete mode 100644 src/tools/ast-grep/AGENTS.md delete mode 100644 src/tools/ast-grep/cli-binary-path-resolution.ts delete mode 100644 src/tools/ast-grep/cli.ts delete mode 100644 src/tools/ast-grep/constants.ts delete mode 100644 src/tools/ast-grep/downloader.ts delete mode 100644 src/tools/ast-grep/environment-check.ts delete mode 100644 src/tools/ast-grep/index.ts delete mode 100644 src/tools/ast-grep/language-support.ts delete mode 100644 src/tools/ast-grep/pattern-hints.test.ts delete mode 100644 src/tools/ast-grep/pattern-hints.ts delete mode 100644 src/tools/ast-grep/process-output-timeout.ts delete mode 100644 src/tools/ast-grep/result-formatter.ts delete mode 100644 src/tools/ast-grep/sg-cli-path.ts delete mode 100644 src/tools/ast-grep/sg-compact-json-output.ts delete mode 100644 src/tools/ast-grep/tool-descriptions.test.ts delete mode 100644 src/tools/ast-grep/tool-descriptions.ts delete mode 100644 src/tools/ast-grep/tools.test.ts delete mode 100644 src/tools/ast-grep/tools.ts delete mode 100644 src/tools/ast-grep/types.ts diff --git a/src/plugin/tool-registry.team-mode.test.ts b/src/plugin/tool-registry.team-mode.test.ts index bdf995497..7e3926b85 100644 --- a/src/plugin/tool-registry.team-mode.test.ts +++ b/src/plugin/tool-registry.team-mode.test.ts @@ -90,7 +90,6 @@ describe("team-mode tool registry wiring", () => { createSkillTool: mock(() => fakeTool), createGrepTools: mock(() => ({})), createGlobTools: mock(() => ({})), - createAstGrepTools: mock(() => ({})), createSessionManagerTools: mock(() => ({})), createDelegateTask: mock(() => fakeTool), discoverCommandsSync: mock(() => []), @@ -164,7 +163,6 @@ describe("team-mode tool registry wiring", () => { createSkillTool: mock(() => fakeTool), createGrepTools: mock(() => ({})), createGlobTools: mock(() => ({})), - createAstGrepTools: mock(() => ({})), createSessionManagerTools: mock(() => ({})), createDelegateTask: mock(() => fakeTool), discoverCommandsSync: mock(() => []), diff --git a/src/plugin/tool-registry.test.ts b/src/plugin/tool-registry.test.ts index 03a8ec5a9..5273b70ca 100644 --- a/src/plugin/tool-registry.test.ts +++ b/src/plugin/tool-registry.test.ts @@ -53,7 +53,6 @@ const toolFactories: NonNullable[0]["toolF createSkillTool: mock(() => fakeTool), createGrepTools: mock(() => ({})), createGlobTools: mock(() => ({})), - createAstGrepTools: mock(() => ({})), createSessionManagerTools: mock(() => ({})), createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => { syncSessionCreatedCallbacks.push(options.onSyncSessionCreated) diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 2b249acc6..1ad6ced34 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -32,7 +32,6 @@ import { createSkillTool, createGrepTools, createGlobTools, - createAstGrepTools, createSessionManagerTools, createDelegateTask, discoverCommandsSync, @@ -59,7 +58,6 @@ type ToolRegistryFactories = { createSkillTool: typeof createSkillTool createGrepTools: typeof createGrepTools createGlobTools: typeof createGlobTools - createAstGrepTools: typeof createAstGrepTools createSessionManagerTools: typeof createSessionManagerTools createDelegateTask: typeof createDelegateTask discoverCommandsSync: typeof discoverCommandsSync @@ -91,7 +89,6 @@ const defaultToolRegistryFactories: ToolRegistryFactories = { createSkillTool, createGrepTools, createGlobTools, - createAstGrepTools, createSessionManagerTools, createDelegateTask, discoverCommandsSync, @@ -338,7 +335,6 @@ export function createToolRegistry(args: { const allTools: Record = { ...factories.createGrepTools(ctx), ...factories.createGlobTools(ctx), - ...factories.createAstGrepTools(ctx), ...factories.createSessionManagerTools(ctx), ...backgroundTools, call_omo_agent: callOmoAgent, diff --git a/src/tools/ast-grep/AGENTS.md b/src/tools/ast-grep/AGENTS.md deleted file mode 100644 index 08ef8407c..000000000 --- a/src/tools/ast-grep/AGENTS.md +++ /dev/null @@ -1,54 +0,0 @@ -# src/tools/ast-grep/ -- AST-Aware Search and Rewrite - -**Generated:** 2026-05-18 - -## OVERVIEW - -Two always-on tools: `ast_grep_search` (find AST patterns) and `ast_grep_replace` (rewrite AST patterns). 25 languages supported via `@ast-grep/napi` as primary backend with fallback to `sg` CLI. - -Pattern syntax uses AST meta-variables, not regex. `$VAR` matches one AST node. `$$$` matches zero or more nodes. `$$$VAR` captures a named list. Patterns must be complete, parseable source code. - -`ast_grep_replace` defaults to dry-run. Pass `dryRun=false` to apply changes. - -## FILE CATALOG - -| File | Role | -|------|------| -| `tools.ts` | `createAstGrepTools` factory -- returns Record with 2 tool entries | -| `cli.ts` | `runSg` -- spawns sg process, handles two-pass rewrite | -| `cli-binary-path-resolution.ts` | Async init wrapper with singleton promise dedup | -| `sg-cli-path.ts` | Resolve sg via node_modules, platform subpackages, Homebrew, or cache | -| `downloader.ts` | Auto-download from GitHub releases if missing | -| `environment-check.ts` | Verify CLI + NAPI availability at startup | -| `language-support.ts` | 25 CLI languages + 5 NAPI languages + extension map | -| `pattern-hints.ts` | Detect regex misuse and language-specific mistakes | -| `result-formatter.ts` | Format matches with file:line:column for LLM | -| `sg-compact-json-output.ts` | Parse `sg --json=compact` into `SgResult` | -| `tool-descriptions.ts` | Tool description constants | -| `process-output-timeout.ts` | 300s timeout wrapper for spawn | -| `types.ts` | `CliMatch`, `SgResult`, `AnalyzeResult`, etc. | -| `constants.ts` | Re-exports from language-support, environment-check, sg-cli-path | -| `index.ts` | Barrel | - -## KEY BEHAVIORS - -- Dual binary detection: NAPI primary, CLI fallback -- Fallback chain: node_modules → platform subpackage → Homebrew → cached download -- Dry-run protection: `ast_grep_replace` defaults to preview; pass `dryRun=false` to apply -- Two-pass rewrite: when rewrite + apply both requested, cli.ts runs `--json=compact` first, then `--update-all` -- Output limits: 1MB max output or 500 matches, whichever comes first -- Timeout: 300s cap via `process-output-timeout.ts`; kills process and returns truncated result - -## LANGUAGES - -25 CLI languages: bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml. - -5 NAPI languages (native bindings): html, javascript, tsx, css, typescript. - -## PATTERN HINTS - -When a search returns zero matches, `pattern-hints.ts` scans for regex-style misuse (`|`, `.*`, `\w`, `[a-z]`) and returns a corrective hint redirecting to ast-grep meta-variable syntax. Also catches language-specific mistakes like trailing colons in Python def/class patterns or incomplete function signatures in JS/Go/Rust. - -## RELATED - -Doctor check at `src/cli/doctor/checks/tools.ts` verifies both NAPI and CLI availability. diff --git a/src/tools/ast-grep/cli-binary-path-resolution.ts b/src/tools/ast-grep/cli-binary-path-resolution.ts deleted file mode 100644 index 7fbd760ef..000000000 --- a/src/tools/ast-grep/cli-binary-path-resolution.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync } from "fs" - -import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants" -import { ensureAstGrepBinary } from "./downloader" - -let resolvedCliPath: string | null = null -let initPromise: Promise | null = null - -export async function getAstGrepPath(): Promise { - if (resolvedCliPath !== null && existsSync(resolvedCliPath)) { - return resolvedCliPath - } - - if (initPromise) { - return initPromise - } - - initPromise = (async () => { - const syncPath = findSgCliPathSync() - if (syncPath && existsSync(syncPath)) { - resolvedCliPath = syncPath - setSgCliPath(syncPath) - return syncPath - } - - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - resolvedCliPath = downloadedPath - setSgCliPath(downloadedPath) - return downloadedPath - } - - return null - })() - - return initPromise -} - -export function startBackgroundInit(): void { - if (!initPromise) { - initPromise = getAstGrepPath() - initPromise.catch(() => {}) - } -} - -export function isCliAvailable(): boolean { - const path = findSgCliPathSync() - return path !== null && existsSync(path) -} - -export async function ensureCliAvailable(): Promise { - const path = await getAstGrepPath() - return path !== null && existsSync(path) -} - -export function getResolvedSgCliPath(): string | null { - const path = getSgCliPath() - if (path && existsSync(path)) return path - return null -} diff --git a/src/tools/ast-grep/cli.ts b/src/tools/ast-grep/cli.ts deleted file mode 100644 index 2d76975f7..000000000 --- a/src/tools/ast-grep/cli.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { spawn } from "../../shared/bun-spawn-shim" -import { existsSync } from "fs" -import { - getSgCliPath, - DEFAULT_TIMEOUT_MS, -} from "./constants" -import { ensureAstGrepBinary } from "./downloader" -import type { CliLanguage, SgResult } from "./types" - -import { getAstGrepPath } from "./cli-binary-path-resolution" -import { collectProcessOutputWithTimeout } from "./process-output-timeout" -import { createSgResultFromStdout } from "./sg-compact-json-output" - -export { - ensureCliAvailable, - getAstGrepPath, - isCliAvailable, - startBackgroundInit, -} from "./cli-binary-path-resolution" - -export interface RunOptions { - pattern: string - lang: CliLanguage - paths?: string[] - globs?: string[] - rewrite?: string - context?: number - updateAll?: boolean -} - -export async function runSg(options: RunOptions): Promise { - // ast-grep CLI silently ignores --update-all when --json is present. - // When both rewrite and updateAll are requested, we must run two separate - // invocations: one with --json=compact to collect match results, and - // another with --update-all to perform the actual file writes. - const shouldSeparateWritePass = !!(options.rewrite && options.updateAll) - - const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"] - - if (options.rewrite) { - args.push("-r", options.rewrite) - if (options.updateAll && !shouldSeparateWritePass) { - args.push("--update-all") - } - } - - if (options.context && options.context > 0) { - args.push("-C", String(options.context)) - } - - if (options.globs) { - for (const glob of options.globs) { - args.push("--globs", glob) - } - } - - const paths = options.paths && options.paths.length > 0 ? options.paths : ["."] - args.push(...paths) - - let cliPath = getSgCliPath() - - if (!cliPath || !existsSync(cliPath)) { - const downloadedPath = await getAstGrepPath() - if (downloadedPath) { - cliPath = downloadedPath - } else { - return { - matches: [], - totalMatches: 0, - truncated: false, - error: - `ast-grep (sg) binary not found.\n\n` + - `Install options:\n` + - ` bun add -D @ast-grep/cli\n` + - ` cargo install ast-grep --locked\n` + - ` brew install ast-grep`, - } - } - } - - const timeout = DEFAULT_TIMEOUT_MS - - const proc = spawn([cliPath, ...args], { - stdout: "pipe", - stderr: "pipe", - }) - - let stdout: string - let stderr: string - let exitCode: number - - try { - const output = await collectProcessOutputWithTimeout(proc, timeout) - stdout = output.stdout - stderr = output.stderr - exitCode = output.exitCode - } catch (error) { - if (error instanceof Error && error.message.includes("timeout")) { - return { - matches: [], - totalMatches: 0, - truncated: true, - truncatedReason: "timeout", - error: error.message, - } - } - - const errorMessage = error instanceof Error ? error.message : String(error) - const errorCode = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: unknown }).code - : undefined - const isNoEntry = - errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found") - - if (isNoEntry) { - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - return runSg(options) - } else { - return { - matches: [], - totalMatches: 0, - truncated: false, - error: - `ast-grep CLI binary not found.\n\n` + - `Auto-download failed. Manual install options:\n` + - ` bun add -D @ast-grep/cli\n` + - ` cargo install ast-grep --locked\n` + - ` brew install ast-grep`, - } - } - } - - return { - matches: [], - totalMatches: 0, - truncated: false, - error: `Failed to spawn ast-grep: ${errorMessage}`, - } - } - - if (exitCode !== 0 && stdout.trim() === "") { - if (stderr.includes("No files found")) { - return { matches: [], totalMatches: 0, truncated: false } - } - if (stderr.trim()) { - return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() } - } - return { matches: [], totalMatches: 0, truncated: false } - } - - const jsonResult = createSgResultFromStdout(stdout) - - if (shouldSeparateWritePass && jsonResult.matches.length > 0) { - const writeArgs = args.filter(a => a !== "--json=compact") - writeArgs.push("--update-all") - - const writeProc = spawn([cliPath, ...writeArgs], { - stdout: "pipe", - stderr: "pipe", - }) - - try { - const writeOutput = await collectProcessOutputWithTimeout(writeProc, timeout) - if (writeOutput.exitCode !== 0) { - const errorDetail = writeOutput.stderr.trim() || `ast-grep exited with code ${writeOutput.exitCode}` - return { ...jsonResult, error: `Replace failed: ${errorDetail}` } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { ...jsonResult, error: `Replace failed: ${errorMessage}` } - } - } - - return jsonResult -} diff --git a/src/tools/ast-grep/constants.ts b/src/tools/ast-grep/constants.ts deleted file mode 100644 index 8ceba1733..000000000 --- a/src/tools/ast-grep/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { EnvironmentCheckResult } from "./environment-check" -export { checkEnvironment, formatEnvironmentCheck } from "./environment-check" -export { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./language-support" -export { DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support" -export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path" diff --git a/src/tools/ast-grep/downloader.ts b/src/tools/ast-grep/downloader.ts deleted file mode 100644 index 0c7389cf4..000000000 --- a/src/tools/ast-grep/downloader.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { existsSync } from "fs" -import { join } from "path" -import { homedir } from "os" -import { createRequire } from "module" -import { - cleanupArchive, - downloadArchive, - ensureCacheDir, - ensureExecutable, - extractZipArchive, - getCachedBinaryPath as getCachedBinaryPathShared, -} from "../../shared/binary-downloader" -import { log } from "../../shared/logger" -import { CACHE_DIR_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity" - -const REPO = "ast-grep/ast-grep" - -// IMPORTANT: Update this when bumping @ast-grep/cli in package.json -// This is only used as fallback when @ast-grep/cli package.json cannot be read -const DEFAULT_VERSION = "0.41.1" - -function getAstGrepVersion(): string { - try { - const require = createRequire(import.meta.url) - const pkg = require("@ast-grep/cli/package.json") - return pkg.version - } catch { - return DEFAULT_VERSION - } -} - -interface PlatformInfo { - arch: string - os: string -} - -const PLATFORM_MAP: Record = { - "darwin-arm64": { arch: "aarch64", os: "apple-darwin" }, - "darwin-x64": { arch: "x86_64", os: "apple-darwin" }, - "linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" }, - "linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" }, - "win32-x64": { arch: "x86_64", os: "pc-windows-msvc" }, - "win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" }, - "win32-ia32": { arch: "i686", os: "pc-windows-msvc" }, -} - -export function getCacheDir(): string { - if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA - const base = localAppData || join(homedir(), "AppData", "Local") - return join(base, CACHE_DIR_NAME, "bin") - } - - const xdgCache = process.env.XDG_CACHE_HOME - const base = xdgCache || join(homedir(), ".cache") - return join(base, CACHE_DIR_NAME, "bin") -} - -export function getBinaryName(): string { - return process.platform === "win32" ? "sg.exe" : "sg" -} - -export function getCachedBinaryPath(): string | null { - return getCachedBinaryPathShared(getCacheDir(), getBinaryName()) -} - - - -export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise { - const platformKey = `${process.platform}-${process.arch}` - const platformInfo = PLATFORM_MAP[platformKey] - - if (!platformInfo) { - log(`[${PUBLISHED_PACKAGE_NAME}] Unsupported platform for ast-grep: ${platformKey}`) - return null - } - - const cacheDir = getCacheDir() - const binaryName = getBinaryName() - const binaryPath = join(cacheDir, binaryName) - - if (existsSync(binaryPath)) { - return binaryPath - } - - const { arch, os } = platformInfo - const assetName = `app-${arch}-${os}.zip` - const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}` - - log(`[${PUBLISHED_PACKAGE_NAME}] Downloading ast-grep binary...`) - - try { - const archivePath = join(cacheDir, assetName) - ensureCacheDir(cacheDir) - await downloadArchive(downloadUrl, archivePath) - await extractZipArchive(archivePath, cacheDir) - cleanupArchive(archivePath) - ensureExecutable(binaryPath) - - log(`[${PUBLISHED_PACKAGE_NAME}] ast-grep binary ready.`) - - return binaryPath - } catch (err) { - log( - `[${PUBLISHED_PACKAGE_NAME}] Failed to download ast-grep: ${err instanceof Error ? err.message : err}` - ) - return null - } -} - -export async function ensureAstGrepBinary(): Promise { - const cachedPath = getCachedBinaryPath() - if (cachedPath) { - return cachedPath - } - - const version = getAstGrepVersion() - return downloadAstGrep(version) -} diff --git a/src/tools/ast-grep/environment-check.ts b/src/tools/ast-grep/environment-check.ts deleted file mode 100644 index d78b7b09d..000000000 --- a/src/tools/ast-grep/environment-check.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { existsSync } from "fs" - -import { CLI_LANGUAGES, NAPI_LANGUAGES } from "./language-support" -import { getSgCliPath } from "./sg-cli-path" - -export interface EnvironmentCheckResult { - cli: { - available: boolean - path: string - error?: string - } - napi: { - available: boolean - error?: string - } -} - -/** - * Check if ast-grep CLI and NAPI are available. - * Call this at startup to provide early feedback about missing dependencies. - */ -export function checkEnvironment(): EnvironmentCheckResult { - const cliPath = getSgCliPath() - const result: EnvironmentCheckResult = { - cli: { - available: false, - path: cliPath ?? "not found", - }, - napi: { - available: false, - }, - } - - if (cliPath && existsSync(cliPath)) { - result.cli.available = true - } else if (!cliPath) { - result.cli.error = "ast-grep binary not found. Install with: bun add -D @ast-grep/cli" - } else { - result.cli.error = `Binary not found: ${cliPath}` - } - - // Check NAPI availability - try { - require("@ast-grep/napi") - result.napi.available = true - } catch (error) { - result.napi.available = false - result.napi.error = `@ast-grep/napi not installed: ${ - error instanceof Error ? error.message : String(error) - }` - } - - return result -} - -/** - * Format environment check result as user-friendly message. - */ -export function formatEnvironmentCheck(result: EnvironmentCheckResult): string { - const lines: string[] = ["ast-grep Environment Status:", ""] - - // CLI status - if (result.cli.available) { - lines.push(`[OK] CLI: Available (${result.cli.path})`) - } else { - lines.push("[X] CLI: Not available") - if (result.cli.error) { - lines.push(` Error: ${result.cli.error}`) - } - lines.push(" Install: bun add -D @ast-grep/cli") - } - - // NAPI status - if (result.napi.available) { - lines.push("[OK] NAPI: Available") - } else { - lines.push("[X] NAPI: Not available") - if (result.napi.error) { - lines.push(` Error: ${result.napi.error}`) - } - lines.push(" Install: bun add -D @ast-grep/napi") - } - - lines.push("") - lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`) - lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`) - - return lines.join("\n") -} diff --git a/src/tools/ast-grep/index.ts b/src/tools/ast-grep/index.ts deleted file mode 100644 index 7356d000b..000000000 --- a/src/tools/ast-grep/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { createAstGrepTools } from "./tools" -export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader" -export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli" -export { checkEnvironment, formatEnvironmentCheck } from "./constants" -export type { EnvironmentCheckResult } from "./constants" diff --git a/src/tools/ast-grep/language-support.ts b/src/tools/ast-grep/language-support.ts deleted file mode 100644 index b8abc9e9a..000000000 --- a/src/tools/ast-grep/language-support.ts +++ /dev/null @@ -1,63 +0,0 @@ -// CLI supported languages (25 total) -export const CLI_LANGUAGES = [ - "bash", - "c", - "cpp", - "csharp", - "css", - "elixir", - "go", - "haskell", - "html", - "java", - "javascript", - "json", - "kotlin", - "lua", - "nix", - "php", - "python", - "ruby", - "rust", - "scala", - "solidity", - "swift", - "typescript", - "tsx", - "yaml", -] as const - -// NAPI supported languages (5 total - native bindings) -export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const - -export const DEFAULT_TIMEOUT_MS = 300_000 -export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024 -export const DEFAULT_MAX_MATCHES = 500 - -export const LANG_EXTENSIONS: Record = { - bash: [".bash", ".sh", ".zsh", ".bats"], - c: [".c", ".h"], - cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"], - csharp: [".cs"], - css: [".css"], - elixir: [".ex", ".exs"], - go: [".go"], - haskell: [".hs", ".lhs"], - html: [".html", ".htm"], - java: [".java"], - javascript: [".js", ".jsx", ".mjs", ".cjs"], - json: [".json"], - kotlin: [".kt", ".kts"], - lua: [".lua"], - nix: [".nix"], - php: [".php"], - python: [".py", ".pyi"], - ruby: [".rb", ".rake"], - rust: [".rs"], - scala: [".scala", ".sc"], - solidity: [".sol"], - swift: [".swift"], - typescript: [".ts", ".cts", ".mts"], - tsx: [".tsx"], - yaml: [".yml", ".yaml"], -} diff --git a/src/tools/ast-grep/pattern-hints.test.ts b/src/tools/ast-grep/pattern-hints.test.ts deleted file mode 100644 index 626a640eb..000000000 --- a/src/tools/ast-grep/pattern-hints.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -/// - -import { describe, expect, it } from "bun:test" -import { - detectLanguageSpecificMistake, - detectRegexMisuse, - getPatternHint, -} from "./pattern-hints" - -describe("detectRegexMisuse", () => { - describe("#given pure regex alternation", () => { - it("#when pattern is lowercase alternation #then returns alternation hint", () => { - // given - const pattern = "watch|WatchMode|--watch" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).not.toBeNull() - expect(hint).toContain("|") - expect(hint).toContain("alternation") - expect(hint).toContain("grep") - }) - - it("#when pattern is camelCase alternation #then returns alternation hint", () => { - // given - const pattern = "noEmit|NoEmit" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toContain("alternation") - }) - - it("#when pattern mixes wildcard and alternation #then returns a hint", () => { - // given - const pattern = "func.*build|BuildMode|projectReferences" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).not.toBeNull() - }) - }) - - describe("#given valid AST patterns using |", () => { - it("#when pattern uses meta-vars around pipe (bitwise OR) #then returns null", () => { - // given - const pattern = "$A | $B" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - - it("#when pattern is a Rust closure #then returns null", () => { - // given - const pattern = "|x| x + 1" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - }) - - describe("#given regex escape sequences", () => { - it("#when pattern contains \\w #then returns regex-escape hint", () => { - // given - const pattern = "\\w+Mode" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toContain("regex escape") - expect(hint).toContain("grep") - }) - - it("#when pattern contains \\d #then returns regex-escape hint", () => { - // given - const pattern = "id\\d+" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toContain("regex escape") - }) - }) - - describe("#given character class ranges", () => { - it("#when pattern contains [a-z] #then returns character-class hint", () => { - // given - const pattern = "[a-z]+Mode" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toContain("character classes") - expect(hint).toContain("grep") - }) - - it("#when pattern contains [0-9] #then returns character-class hint", () => { - // given - const pattern = "v[0-9]+" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toContain("character classes") - }) - }) - - describe("#given regex wildcards embedded in identifiers", () => { - it("#when pattern uses foo.*bar without meta-vars #then returns wildcard hint", () => { - // given - const pattern = "func.*build" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toContain("regex wildcards") - expect(hint).toContain("$$$") - }) - - it("#when pattern uses $$$ (proper AST) #then returns null", () => { - // given - const pattern = "func $NAME($$$) { $$$ }" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - }) - - describe("#given legitimate AST patterns", () => { - it("#when pattern is a JS function #then returns null", () => { - // given - const pattern = "function $NAME($$$) { $$$ }" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - - it("#when pattern is console.log call #then returns null", () => { - // given - const pattern = "console.log($$$)" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - - it("#when pattern is a Python def #then returns null", () => { - // given - const pattern = "def $FUNC($$$)" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - - it("#when pattern is array access a[0] #then returns null (not character class)", () => { - // given - const pattern = "$A[0]" - - // when - const hint = detectRegexMisuse(pattern) - - // then - expect(hint).toBeNull() - }) - }) -}) - -describe("detectLanguageSpecificMistake", () => { - describe("#given a Python def with trailing colon", () => { - it("#when lang is python #then suggests removing the colon", () => { - // given - const pattern = "def $FUNC($$$):" - - // when - const hint = detectLanguageSpecificMistake(pattern, "python") - - // then - expect(hint).toContain("Remove trailing colon") - expect(hint).toContain("def $FUNC($$$)") - }) - }) - - describe("#given a Python class with trailing colon", () => { - it("#when lang is python #then suggests removing the colon", () => { - // given - const pattern = "class $C:" - - // when - const hint = detectLanguageSpecificMistake(pattern, "python") - - // then - expect(hint).toContain("Remove trailing colon") - }) - }) - - describe("#given a TypeScript function with no body", () => { - it("#when lang is typescript #then suggests adding params and body", () => { - // given - const pattern = "function $NAME" - - // when - const hint = detectLanguageSpecificMistake(pattern, "typescript") - - // then - expect(hint).toContain("params and body") - expect(hint).toContain("function $NAME($$$) { $$$ }") - }) - }) - - describe("#given a Go function with no body", () => { - it("#when lang is go #then suggests Go function template", () => { - // given - const pattern = "func $NAME" - - // when - const hint = detectLanguageSpecificMistake(pattern, "go") - - // then - expect(hint).not.toBeNull() - expect(hint).toContain("func $NAME($$$) { $$$ }") - }) - }) - - describe("#given a Rust fn with no body", () => { - it("#when lang is rust #then suggests Rust fn template", () => { - // given - const pattern = "fn $NAME" - - // when - const hint = detectLanguageSpecificMistake(pattern, "rust") - - // then - expect(hint).not.toBeNull() - expect(hint).toContain("fn $NAME($$$) { $$$ }") - }) - }) -}) - -describe("getPatternHint", () => { - it("#given regex alternation #when composing #then regex hint wins over language check", () => { - // given - const pattern = "foo|bar" - - // when - const hint = getPatternHint(pattern, "typescript") - - // then - expect(hint).toContain("alternation") - }) - - it("#given a clean AST pattern #when composing #then returns null", () => { - // given - const pattern = "function $NAME($$$) { $$$ }" - - // when - const hint = getPatternHint(pattern, "typescript") - - // then - expect(hint).toBeNull() - }) - - it("#given a Python def with trailing colon #when composing #then returns the colon hint", () => { - // given - const pattern = "def $FUNC($$$):" - - // when - const hint = getPatternHint(pattern, "python") - - // then - expect(hint).toContain("Remove trailing colon") - }) -}) diff --git a/src/tools/ast-grep/pattern-hints.ts b/src/tools/ast-grep/pattern-hints.ts deleted file mode 100644 index 8370b830e..000000000 --- a/src/tools/ast-grep/pattern-hints.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { CliLanguage } from "./types" - -export function detectRegexMisuse(pattern: string): string | null { - const src = pattern.trim() - - if (/\\[wWdDsSbB]/.test(src)) { - return 'Hint: "\\w", "\\d", "\\s", "\\b" are regex escapes. ast-grep matches AST nodes, not text - use $VAR for identifiers, $$$ for node lists, or switch to grep for text search.' - } - - if (/\[[a-zA-Z0-9]-[a-zA-Z0-9]\]/.test(src)) { - return 'Hint: "[a-z]" and similar character classes are regex, not AST. Use $VAR to match any identifier, or switch to grep for text search.' - } - - if (!src.includes("$") && /\w\.[*+]/.test(src)) { - return 'Hint: ".*" and ".+" are regex wildcards. In ast-grep use $$$ for multiple AST nodes and $VAR for a single node. For text patterns, switch to grep.' - } - - if (/^[-\w.*]+\|[-\w.*|]+$/.test(src)) { - return 'Hint: "|" is regex alternation and does NOT work in ast-grep patterns. Options: (a) fire one ast_grep_search per alternative, or (b) switch to grep with a regex pattern like "foo|bar".' - } - - return null -} - -export function detectLanguageSpecificMistake( - pattern: string, - lang: CliLanguage, -): string | null { - const src = pattern.trim() - - if (lang === "python") { - if (src.startsWith("class ") && src.endsWith(":")) { - return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"` - } - if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) { - return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"` - } - } - - if (["javascript", "typescript", "tsx"].includes(lang)) { - if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { - return 'Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"' - } - } - - if (lang === "go") { - if (/^func\s+\$[A-Z_]+\s*$/i.test(src)) { - return 'Hint: Go function patterns need params and body. Try "func $NAME($$$) { $$$ }"' - } - } - - if (lang === "rust") { - if (/^fn\s+\$[A-Z_]+\s*$/i.test(src)) { - return 'Hint: Rust fn patterns need params and body. Try "fn $NAME($$$) { $$$ }"' - } - } - - return null -} - -export function getPatternHint(pattern: string, lang: CliLanguage): string | null { - return detectRegexMisuse(pattern) ?? detectLanguageSpecificMistake(pattern, lang) -} diff --git a/src/tools/ast-grep/process-output-timeout.ts b/src/tools/ast-grep/process-output-timeout.ts deleted file mode 100644 index 2292b37c3..000000000 --- a/src/tools/ast-grep/process-output-timeout.ts +++ /dev/null @@ -1,28 +0,0 @@ -type SpawnedProcess = { - stdout: ReadableStream | null - stderr: ReadableStream | null - exited: Promise - kill: () => void -} - -export async function collectProcessOutputWithTimeout( - process: SpawnedProcess, - timeoutMs: number -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const timeoutPromise = new Promise((_, reject) => { - const timeoutId = setTimeout(() => { - process.kill() - reject(new Error(`Search timeout after ${timeoutMs}ms`)) - }, timeoutMs) - process.exited.then(() => clearTimeout(timeoutId)) - }) - - const stdoutPromise = process.stdout ? new Response(process.stdout).text() : Promise.resolve("") - const stderrPromise = process.stderr ? new Response(process.stderr).text() : Promise.resolve("") - - const stdout = await Promise.race([stdoutPromise, timeoutPromise]) - const stderr = await stderrPromise - const exitCode = await process.exited - - return { stdout, stderr, exitCode } -} diff --git a/src/tools/ast-grep/result-formatter.ts b/src/tools/ast-grep/result-formatter.ts deleted file mode 100644 index 99eb33caa..000000000 --- a/src/tools/ast-grep/result-formatter.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { AnalyzeResult, SgResult } from "./types" - -export function formatSearchResult(result: SgResult): string { - if (result.error) { - return `Error: ${result.error}` - } - - if (result.matches.length === 0) { - return "No matches found" - } - - const lines: string[] = [] - - if (result.truncated) { - const reason = result.truncatedReason === "max_matches" - ? `showing first ${result.matches.length} of ${result.totalMatches}` - : result.truncatedReason === "max_output_bytes" - ? "output exceeded 1MB limit" - : "search timed out" - lines.push(`[TRUNCATED] Results truncated (${reason})\n`) - } - - lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`) - - for (const match of result.matches) { - const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}` - lines.push(`${loc}`) - lines.push(` ${match.lines.trim()}`) - lines.push("") - } - - return lines.join("\n") -} - -export function formatReplaceResult(result: SgResult, isDryRun: boolean): string { - if (result.error) { - return `Error: ${result.error}` - } - - if (result.matches.length === 0) { - return "No matches found to replace" - } - - const prefix = isDryRun ? "[DRY RUN] " : "" - const lines: string[] = [] - - if (result.truncated) { - const reason = result.truncatedReason === "max_matches" - ? `showing first ${result.matches.length} of ${result.totalMatches}` - : result.truncatedReason === "max_output_bytes" - ? "output exceeded 1MB limit" - : "search timed out" - lines.push(`[TRUNCATED] Results truncated (${reason})\n`) - } - - lines.push(`${prefix}${result.matches.length} replacement(s):\n`) - - for (const match of result.matches) { - const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}` - lines.push(`${loc}`) - lines.push(` ${match.text}`) - lines.push("") - } - - if (isDryRun) { - lines.push("Use dryRun=false to apply changes") - } - - return lines.join("\n") -} - -export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars: boolean): string { - if (results.length === 0) { - return "No matches found" - } - - const lines: string[] = [`Found ${results.length} match(es):\n`] - - for (const result of results) { - const loc = `L${result.range.start.line + 1}:${result.range.start.column + 1}` - lines.push(`[${loc}] (${result.kind})`) - lines.push(` ${result.text}`) - - if (extractedMetaVars && result.metaVariables.length > 0) { - lines.push(" Meta-variables:") - for (const mv of result.metaVariables) { - lines.push(` $${mv.name} = "${mv.text}" (${mv.kind})`) - } - } - lines.push("") - } - - return lines.join("\n") -} - -export function formatTransformResult(_original: string, transformed: string, editCount: number): string { - if (editCount === 0) { - return "No matches found to transform" - } - - return `Transformed (${editCount} edit(s)):\n\`\`\`\n${transformed}\n\`\`\`` -} diff --git a/src/tools/ast-grep/sg-cli-path.ts b/src/tools/ast-grep/sg-cli-path.ts deleted file mode 100644 index 5d7103f57..000000000 --- a/src/tools/ast-grep/sg-cli-path.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createRequire } from "module" -import { dirname, join } from "path" -import { existsSync, statSync } from "fs" - -import { getCachedBinaryPath } from "./downloader" - -type Platform = "darwin" | "linux" | "win32" | "unsupported" - -function isValidBinary(filePath: string): boolean { - try { - return statSync(filePath).size > 10000 - } catch { - return false - } -} - -function getPlatformPackageName(): string | null { - const platform = process.platform as Platform - const arch = process.arch - - const platformMap: Record = { - "darwin-arm64": "@ast-grep/cli-darwin-arm64", - "darwin-x64": "@ast-grep/cli-darwin-x64", - "linux-arm64": "@ast-grep/cli-linux-arm64-gnu", - "linux-x64": "@ast-grep/cli-linux-x64-gnu", - "win32-x64": "@ast-grep/cli-win32-x64-msvc", - "win32-arm64": "@ast-grep/cli-win32-arm64-msvc", - "win32-ia32": "@ast-grep/cli-win32-ia32-msvc", - } - - return platformMap[`${platform}-${arch}`] ?? null -} - -export function findSgCliPathSync(): string | null { - const binaryName = process.platform === "win32" ? "sg.exe" : "sg" - - const cachedPath = getCachedBinaryPath() - if (cachedPath && isValidBinary(cachedPath)) { - return cachedPath - } - - try { - const require = createRequire(import.meta.url) - const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json") - const cliDirectory = dirname(cliPackageJsonPath) - const sgPath = join(cliDirectory, binaryName) - - if (existsSync(sgPath) && isValidBinary(sgPath)) { - return sgPath - } - } catch { - // @ast-grep/cli not installed - } - - const platformPackage = getPlatformPackageName() - if (platformPackage) { - try { - const require = createRequire(import.meta.url) - const packageJsonPath = require.resolve(`${platformPackage}/package.json`) - const packageDirectory = dirname(packageJsonPath) - const astGrepBinaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep" - const binaryPath = join(packageDirectory, astGrepBinaryName) - - if (existsSync(binaryPath) && isValidBinary(binaryPath)) { - return binaryPath - } - } catch { - // Platform-specific package not installed - } - } - - if (process.platform === "darwin") { - const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"] - for (const path of homebrewPaths) { - if (existsSync(path) && isValidBinary(path)) { - return path - } - } - } - - return null -} - -let resolvedCliPath: string | null = null - -export function getSgCliPath(): string | null { - if (resolvedCliPath !== null) { - return resolvedCliPath - } - - const syncPath = findSgCliPathSync() - if (syncPath) { - resolvedCliPath = syncPath - return syncPath - } - - return null -} - -export function setSgCliPath(path: string): void { - resolvedCliPath = path -} diff --git a/src/tools/ast-grep/sg-compact-json-output.ts b/src/tools/ast-grep/sg-compact-json-output.ts deleted file mode 100644 index 218253165..000000000 --- a/src/tools/ast-grep/sg-compact-json-output.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./constants" -import type { CliMatch, SgResult } from "./types" - -export function createSgResultFromStdout(stdout: string): SgResult { - if (!stdout.trim()) { - return { matches: [], totalMatches: 0, truncated: false } - } - - const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES - const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout - - let matches: CliMatch[] = [] - try { - matches = JSON.parse(outputToProcess) as CliMatch[] - } catch { - if (outputTruncated) { - try { - const lastValidIndex = outputToProcess.lastIndexOf("}") - if (lastValidIndex > 0) { - const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex) - if (bracketIndex > 0) { - const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]" - matches = JSON.parse(truncatedJson) as CliMatch[] - } - } - } catch { - return { - matches: [], - totalMatches: 0, - truncated: true, - truncatedReason: "max_output_bytes", - error: "Output too large and could not be parsed", - } - } - } else { - return { matches: [], totalMatches: 0, truncated: false } - } - } - - const totalMatches = matches.length - const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES - const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches - - return { - matches: finalMatches, - totalMatches, - truncated: outputTruncated || matchesTruncated, - truncatedReason: outputTruncated - ? "max_output_bytes" - : matchesTruncated - ? "max_matches" - : undefined, - } -} diff --git a/src/tools/ast-grep/tool-descriptions.test.ts b/src/tools/ast-grep/tool-descriptions.test.ts deleted file mode 100644 index 4cd3835c0..000000000 --- a/src/tools/ast-grep/tool-descriptions.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/// - -import { describe, expect, it } from "bun:test" -import { - AST_GREP_REPLACE_DESCRIPTION, - AST_GREP_SEARCH_DESCRIPTION, - AST_GREP_SEARCH_PATTERN_PARAM, -} from "./tool-descriptions" - -describe("AST_GREP_SEARCH_DESCRIPTION", () => { - it("#given the description #when inspecting #then asserts it is NOT regex", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("NOT regex") - }) - - it("#given the description #when inspecting #then explains meta-variables $VAR and $$$", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("$VAR") - expect(description).toContain("$$$") - }) - - it("#given the description #when inspecting #then warns against regex alternation", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("alternation") - expect(description).toContain("|") - }) - - it("#given the description #when inspecting #then warns against regex wildcards", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain(".*") - expect(description).toContain("wildcards") - }) - - it("#given the description #when inspecting #then warns against regex escapes", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("\\w") - }) - - it("#given the description #when inspecting #then warns against character classes", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("[a-z]") - }) - - it("#given the description #when inspecting #then tells LLM to use grep as fallback", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description.toLowerCase()).toContain("grep") - }) - - it("#given the description #when showing Python example #then omits the trailing colon bug", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).not.toContain("def $FUNC($$$):") - expect(description).toContain("def $FUNC($$$)") - }) - - it("#given the description #when inspecting #then shows TypeScript example", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("typescript") - expect(description).toContain("function $NAME($$$) { $$$ }") - }) - - it("#given the description #when inspecting #then shows Go example", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("go") - expect(description).toContain("func $NAME($$$) { $$$ }") - }) - - it("#given the description #when inspecting #then shows Rust example", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description).toContain("rust") - expect(description).toContain("fn $NAME(") - }) - - it("#given the description #when measuring #then stays within a token-reasonable length", () => { - // given / when - const description = AST_GREP_SEARCH_DESCRIPTION - - // then - expect(description.length).toBeLessThan(2000) - expect(description.length).toBeGreaterThan(400) - }) -}) - -describe("AST_GREP_SEARCH_PATTERN_PARAM", () => { - it("#given the param description #when inspecting #then states meta-var rules", () => { - // given / when - const description = AST_GREP_SEARCH_PATTERN_PARAM - - // then - expect(description).toContain("$VAR") - expect(description).toContain("$$$") - }) - - it("#given the param description #when inspecting #then forbids regex syntax", () => { - // given / when - const description = AST_GREP_SEARCH_PATTERN_PARAM - - // then - expect(description).toContain("NOT regex") - expect(description).toContain("|") - expect(description).toContain(".*") - }) - - it("#given the param description #when inspecting #then directs to grep for fallback", () => { - // given / when - const description = AST_GREP_SEARCH_PATTERN_PARAM - - // then - expect(description.toLowerCase()).toContain("grep") - }) -}) - -describe("AST_GREP_REPLACE_DESCRIPTION", () => { - it("#given the description #when inspecting #then mentions AST meta-variables", () => { - // given / when - const description = AST_GREP_REPLACE_DESCRIPTION - - // then - expect(description).toContain("$VAR") - expect(description).toContain("$$$") - }) - - it("#given the description #when inspecting #then warns against regex", () => { - // given / when - const description = AST_GREP_REPLACE_DESCRIPTION - - // then - expect(description.toLowerCase()).toContain("regex does not work") - }) - - it("#given the description #when inspecting #then provides an example", () => { - // given / when - const description = AST_GREP_REPLACE_DESCRIPTION - - // then - expect(description).toContain("console.log($MSG)") - expect(description).toContain("logger.info($MSG)") - }) -}) diff --git a/src/tools/ast-grep/tool-descriptions.ts b/src/tools/ast-grep/tool-descriptions.ts deleted file mode 100644 index c15872e47..000000000 --- a/src/tools/ast-grep/tool-descriptions.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const AST_GREP_SEARCH_DESCRIPTION = [ - "Search code by AST structure (25 languages). This is NOT regex.", - "", - "Meta-variables (the only wildcards ast-grep understands):", - " $VAR - one AST node (an identifier, expression, statement, ...)", - " $$$ - zero or more nodes (argument lists, function bodies, ...)", - " $$$VAR - same, captured by name", - "Patterns must be complete, parseable source code. Each meta-variable replaces a whole node, not a substring.", - "", - "Regex syntax does NOT work - never pass these to pattern:", - ' "foo|bar" alternation → run separate calls, or switch to grep', - ' ".*", ".+" wildcards → use $$$ between AST fragments', - ' "\\w", "\\d" escapes → use $VAR to capture any identifier', - ' "[a-z]" class ranges → no AST equivalent', - "For text search, cross-language search, or regex features, use the grep tool instead.", - "", - "Examples by language:", - ' typescript/tsx "function $NAME($$$) { $$$ }", "console.log($$$)", "import { $$$ } from \'$MOD\'"', - ' python "def $FUNC($$$)", "class $C($$$)" - no trailing colon', - ' go "func $NAME($$$) { $$$ }", "if err != nil { $$$ }"', - ' rust "fn $NAME($$$) -> $RET { $$$ }", "impl $TRAIT for $T { $$$ }"', - "", - "On empty results the tool returns a hint naming the exact mistake. If the pattern is fundamentally text-shaped, stop retrying and switch to grep.", -].join("\n") - -export const AST_GREP_SEARCH_PATTERN_PARAM = - "AST pattern - valid, parseable code using $VAR (one node) and $$$ (many nodes). NOT regex: no `|`, no `.*`, no `\\w`, no `[a-z]`. For text or alternation, use grep instead." - -export const AST_GREP_REPLACE_DESCRIPTION = [ - "Rewrite code by AST pattern (25 languages). Dry-run by default.", - "Both pattern and rewrite use AST syntax ($VAR for one node, $$$ for many) - regex does NOT work.", - "Meta-variables captured in pattern can be reused in rewrite to preserve matched content.", - 'Example: pattern="console.log($MSG)" rewrite="logger.info($MSG)"', - "For text-only replacement or regex features, use a text editor instead.", -].join("\n") diff --git a/src/tools/ast-grep/tools.test.ts b/src/tools/ast-grep/tools.test.ts deleted file mode 100644 index b02fa8ea4..000000000 --- a/src/tools/ast-grep/tools.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/// - -import { beforeEach, describe, expect, it, mock } from "bun:test" -import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions" - -const runSgMock = mock(async () => ({ - matches: [], - totalMatches: 0, - truncated: false, -})) - -mock.module("./cli", () => ({ - runSg: runSgMock, -})) - -import { createAstGrepTools } from "./tools" - -describe("createAstGrepTools", () => { - beforeEach(() => { - runSgMock.mockClear() - }) - - it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => { - // given / when - const tools = createAstGrepTools({ directory: "/repo" } as never) - - // then - expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION) - expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION) - expect(tools.ast_grep_search.description).toContain("NOT regex") - }) - - it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => { - // given - const tools = createAstGrepTools({ directory: "/repo" } as never) - - // when - const output = await tools.ast_grep_search.execute( - { pattern: "foo|bar", lang: "typescript" }, - {}, - ) - - // then - expect(output).toContain("No matches found") - expect(output).toContain("alternation") - expect(output).toContain("grep") - expect(runSgMock).toHaveBeenCalledWith({ - pattern: "foo|bar", - lang: "typescript", - paths: ["/repo"], - globs: undefined, - context: undefined, - }) - }) -}) diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts deleted file mode 100644 index 2a2454fd2..000000000 --- a/src/tools/ast-grep/tools.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" -import { CLI_LANGUAGES } from "./constants" -import { runSg } from "./cli" -import { formatSearchResult, formatReplaceResult } from "./result-formatter" -import { getPatternHint } from "./pattern-hints" -import { - AST_GREP_REPLACE_DESCRIPTION, - AST_GREP_SEARCH_DESCRIPTION, - AST_GREP_SEARCH_PATTERN_PARAM, -} from "./tool-descriptions" -import type { CliLanguage } from "./types" - -async function showOutputToUser(context: unknown, output: string): Promise { - const ctx = context as { - metadata?: (input: { metadata: { output: string } }) => void | Promise - } - await ctx.metadata?.({ metadata: { output } }) -} - -export function createAstGrepTools(ctx: PluginInput): Record { - const ast_grep_search: ToolDefinition = tool({ - description: AST_GREP_SEARCH_DESCRIPTION, - args: { - pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM), - lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), - paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"), - globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"), - context: tool.schema.number().optional().describe("Context lines around match"), - }, - execute: async (args, context) => { - try { - const result = await runSg({ - pattern: args.pattern, - lang: args.lang as CliLanguage, - paths: args.paths ?? [ctx.directory], - globs: args.globs, - context: args.context, - }) - - let output = formatSearchResult(result) - - if (result.matches.length === 0 && !result.error) { - const hint = getPatternHint(args.pattern, args.lang as CliLanguage) - if (hint) { - output += `\n\n${hint}` - } - } - - await showOutputToUser(context, output) - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - await showOutputToUser(context, output) - return output - } - }, - }) - - const ast_grep_replace: ToolDefinition = tool({ - description: AST_GREP_REPLACE_DESCRIPTION, - args: { - pattern: tool.schema.string().describe("AST pattern to match"), - rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"), - lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), - paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"), - globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"), - dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"), - }, - execute: async (args, context) => { - try { - const result = await runSg({ - pattern: args.pattern, - rewrite: args.rewrite, - lang: args.lang as CliLanguage, - paths: args.paths ?? [ctx.directory], - globs: args.globs, - updateAll: args.dryRun === false, - }) - const output = formatReplaceResult(result, args.dryRun !== false) - await showOutputToUser(context, output) - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - await showOutputToUser(context, output) - return output - } - }, - }) - - return { ast_grep_search, ast_grep_replace } -} diff --git a/src/tools/ast-grep/types.ts b/src/tools/ast-grep/types.ts deleted file mode 100644 index f3d28cf5f..000000000 --- a/src/tools/ast-grep/types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { CLI_LANGUAGES, NAPI_LANGUAGES } from "./constants" - -export type CliLanguage = (typeof CLI_LANGUAGES)[number] -export type NapiLanguage = (typeof NAPI_LANGUAGES)[number] - -export interface Position { - line: number - column: number -} - -export interface Range { - start: Position - end: Position -} - -export interface CliMatch { - text: string - range: { - byteOffset: { start: number; end: number } - start: Position - end: Position - } - file: string - lines: string - charCount: { leading: number; trailing: number } - language: string -} - -export interface SearchMatch { - file: string - text: string - range: Range - lines: string -} - -export interface MetaVariable { - name: string - text: string - kind: string -} - -export interface AnalyzeResult { - text: string - range: Range - kind: string - metaVariables: MetaVariable[] -} - -export interface TransformResult { - original: string - transformed: string - editCount: number -} - -export interface SgResult { - matches: CliMatch[] - totalMatches: number - truncated: boolean - truncatedReason?: "max_matches" | "max_output_bytes" | "timeout" - error?: string -} diff --git a/src/tools/index.ts b/src/tools/index.ts index af9ee0ce7..2732b2f64 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,3 @@ -export { createAstGrepTools } from "./ast-grep" export { createGrepTools } from "./grep" export { createGlobTools } from "./glob" export { createSkillTool } from "./skill"