diff --git a/.omo/notepads/package-layering-refactor/learnings.md b/.omo/notepads/package-layering-refactor/learnings.md index b22fffed0..704cfa256 100644 --- a/.omo/notepads/package-layering-refactor/learnings.md +++ b/.omo/notepads/package-layering-refactor/learnings.md @@ -21,3 +21,16 @@ - `src/features/boulder-state/format-duration.ts`: pure, zero imports. - `src/shared/jsonc-parser.ts`: coupled to plugin basenames; decoupled via parameterized `detectPluginConfigFile(dir, options)`. - `src/shared/write-file-atomically.ts`: depends on omo-specific `./tolerant-fsync`; extraction deferred by scope decision (kept in-place). + +## [2026-05-21T00:00:00Z] Task 7 (worktree) +- Pre-flight import audit (`packages/ast-grep-mcp/src/*.ts`) shows only `mcp.ts`, `runner.ts`, and `cli-binary-path-resolution.ts` touch adapter/runtime-specific concerns. +- Candidate extracted files are pure from MCP perspective: + - `types.ts`: type-only, currently coupled only by `CliLanguage` source (`CLI_LANGUAGES`). + - `language-support.ts`: CLI language enum + numeric defaults; no MCP/Bun coupling. + - `pattern-hints.ts`: pure heuristics; no runtime coupling (intended identical behavior for pi/codex parity). + - `result-formatter.ts`: pure string formatter over `SgResult`. + - `sg-compact-json-output.ts`: pure JSON parsing/truncation logic; depends only on constants/types. +- `runner.ts` split requirement confirmed: + - Core should own `buildSgArgs()` + `runSg()` orchestration and error mapping. + - OMO-specific binary resolution stays adapter-side (`getAstGrepPath` in `cli-binary-path-resolution.ts`). + - OMO-specific process spawn stays adapter-side (`bun-spawn-shim.ts`), injected via core deps (`spawnProcess`). diff --git a/bun.lock b/bun.lock index fc18fb94b..965a4269a 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "vscode-jsonrpc": "^8.2.1", }, "devDependencies": { + "@oh-my-opencode/ast-grep-core": "workspace:*", "@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/rules-core": "workspace:*", "@oh-my-opencode/utils": "workspace:*", @@ -51,6 +52,10 @@ "zod": "^4.0.0", }, }, + "packages/ast-grep-core": { + "name": "@oh-my-opencode/ast-grep-core", + "version": "0.1.0", + }, "packages/ast-grep-mcp": { "name": "@oh-my-opencode/ast-grep-mcp", "version": "0.0.0", @@ -59,6 +64,7 @@ }, "dependencies": { "@ast-grep/cli": "^0.41.1", + "@oh-my-opencode/ast-grep-core": "workspace:*", }, "devDependencies": { "bun-types": "1.3.12", @@ -151,6 +157,8 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@oh-my-opencode/ast-grep-core": ["@oh-my-opencode/ast-grep-core@workspace:packages/ast-grep-core"], + "@oh-my-opencode/ast-grep-mcp": ["@oh-my-opencode/ast-grep-mcp@workspace:packages/ast-grep-mcp"], "@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"], @@ -451,6 +459,24 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="], + + "@oh-my-opencode/ast-grep-mcp/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-30lrXtyDB+16WS89Bk8sufA5TVUczyQye4PoIYLxZr+PRbPW7thpxHwBwGWL6QvPvUtlElrCe4seA1CEwFxeFA=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jRft57aWRgqYgLXooWxS9Nx5mb5JJ/KQIwEqacWkcmDZEdEui7oG50//6y4/vU5WRcS1n6oB2Vs7WBvTh3/Ypg=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1XUL+8u+Xs1FoM2W6F4v8pRa2aQQcp5CZXBG8uy9n8FhwsQtrhBclJ2Vr9g/zzswHQT1293mnP5TOk1wlYZq6w=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oSsbXzbcl4hnRAw7b1bTFZapx9s+O8ToJJKI44oJAb7xKIG3Rubn2IMBOFvMvjjWEEax8PpS2IocgdB8nUAcbA=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-jTMNqjXnQUhInMB1X06sxWZJv/6pd4/iYSyk8RR5kdulnuNzoGEB9KYbm6ojxktPtMfZpb+7eShQLqqy/dG6Ag=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-mCTyr6/KQneKk0iYaWup4ywW5buNcFqL6TrJVfU0tkd38fu/RtJ5zywr978vVvFxsY+urRU0qkrmtQqXQNwDFA=="], + + "@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-AUbR67UKWsfgyy3SWQq258ZB0xSlaAe15Gl5hPu5tbUu4HTt6rKrUCTEEubYgbNdPPZWtxjobjFjMsDTWfnrug=="], } } diff --git a/package.json b/package.json index 357e8be7b..96d5d593b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "type": "module", "workspaces": [ "packages/rules-core", + "packages/ast-grep-core", "packages/ast-grep-mcp", "packages/utils" ], @@ -43,7 +44,7 @@ "prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build", "test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail", "typecheck": "tsgo --noEmit && bun run typecheck:packages", - "typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json", + "typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "test": "bun test", "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" @@ -87,6 +88,7 @@ "vscode-jsonrpc": "^8.2.1" }, "devDependencies": { + "@oh-my-opencode/ast-grep-core": "workspace:*", "@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/rules-core": "workspace:*", "@oh-my-opencode/utils": "workspace:*", diff --git a/packages/ast-grep-core/package.json b/packages/ast-grep-core/package.json new file mode 100644 index 000000000..facb4d1ba --- /dev/null +++ b/packages/ast-grep-core/package.json @@ -0,0 +1,18 @@ +{ + "name": "@oh-my-opencode/ast-grep-core", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Pure TypeScript ast-grep core logic shared across harness adapters.", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./src/index.ts" + } + }, + "types": "./index.d.ts", + "scripts": { + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test": "bun test src/*.test.ts" + } +} diff --git a/packages/ast-grep-core/src/index.ts b/packages/ast-grep-core/src/index.ts new file mode 100644 index 000000000..4a28055e2 --- /dev/null +++ b/packages/ast-grep-core/src/index.ts @@ -0,0 +1,19 @@ +export { CLI_LANGUAGES, DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS } from "./language-support" +export { getPatternHint, detectLanguageSpecificMistake, detectRegexMisuse } from "./pattern-hints" +export { formatReplaceResult, formatSearchResult } from "./result-formatter" +export { createSgResultFromStdout } from "./sg-compact-json-output" +export { buildSgArgs, runSg } from "./runner" +export type { + CliLanguage, + CliMatch, + Position, + Range, + SgResult, +} from "./types" +export type { + SgRunArgs, + SgRunnerDeps, + SpawnOptions, + SpawnProcess, + SpawnResult, +} from "./runner" diff --git a/packages/ast-grep-core/src/language-support.ts b/packages/ast-grep-core/src/language-support.ts new file mode 100644 index 000000000..52aa996c7 --- /dev/null +++ b/packages/ast-grep-core/src/language-support.ts @@ -0,0 +1,31 @@ +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 + +export const DEFAULT_TIMEOUT_MS = 300_000 +export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024 +export const DEFAULT_MAX_MATCHES = 500 diff --git a/packages/ast-grep-core/src/pattern-hints.ts b/packages/ast-grep-core/src/pattern-hints.ts new file mode 100644 index 000000000..8370b830e --- /dev/null +++ b/packages/ast-grep-core/src/pattern-hints.ts @@ -0,0 +1,63 @@ +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/packages/ast-grep-core/src/result-formatter.ts b/packages/ast-grep-core/src/result-formatter.ts new file mode 100644 index 000000000..b43b451aa --- /dev/null +++ b/packages/ast-grep-core/src/result-formatter.ts @@ -0,0 +1,70 @@ +import type { 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") +} diff --git a/packages/ast-grep-core/src/runner.ts b/packages/ast-grep-core/src/runner.ts new file mode 100644 index 000000000..4d6717664 --- /dev/null +++ b/packages/ast-grep-core/src/runner.ts @@ -0,0 +1,195 @@ +import { DEFAULT_TIMEOUT_MS } from "./language-support" +import { createSgResultFromStdout } from "./sg-compact-json-output" +import type { CliLanguage, SgResult } from "./types" + +const SG_BINARY_NOT_FOUND_MESSAGE = + `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` + +export interface SgRunArgs { + readonly pattern: string + readonly lang: CliLanguage + readonly cwd?: string + readonly paths?: readonly string[] + readonly globs?: readonly string[] + readonly rewrite?: string + readonly context?: number + readonly updateAll?: boolean +} + +export interface SpawnOptions { + readonly cwd?: string + readonly stdout?: "pipe" | "inherit" | "ignore" + readonly stderr?: "pipe" | "inherit" | "ignore" +} + +export interface SpawnResult { + readonly stdout: string + readonly stderr: string + readonly exitCode: number +} + +export type SpawnProcess = ( + binary: string, + args: readonly string[], + options?: SpawnOptions, +) => Promise + +export interface SgRunnerDeps { + readonly resolveBinary: () => Promise + readonly spawnProcess: SpawnProcess +} + +export function buildSgArgs( + options: SgRunArgs, + flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean }, +): string[] { + const args = ["run", "-p", options.pattern, "--lang", options.lang] + + if (flags.includeJson) { + args.push("--json=compact") + } + + if (options.rewrite) { + args.push("-r", options.rewrite) + if (flags.includeUpdateAll) { + args.push("--update-all") + } + } + + if (typeof options.context === "number" && 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) + return args +} + +export async function runSg(options: SgRunArgs, deps: SgRunnerDeps): Promise { + const shouldSeparateWritePass = Boolean(options.rewrite && options.updateAll) + const args = buildSgArgs(options, { includeJson: true, includeUpdateAll: false }) + + let binary: string + try { + binary = await deps.resolveBinary() + } catch (error) { + return { + matches: [], + totalMatches: 0, + truncated: false, + error: isNoEntryError(error) ? SG_BINARY_NOT_FOUND_MESSAGE : `Failed to resolve ast-grep binary: ${errorMessage(error)}`, + } + } + + const searchResult = await trySpawn(binary, args, options.cwd, deps) + if (searchResult.error) { + return searchResult.error + } + + const output = searchResult.value + if (output.exitCode !== 0 && output.stdout.trim() === "") { + if (output.stderr.includes("No files found")) { + return { matches: [], totalMatches: 0, truncated: false } + } + if (output.stderr.trim()) { + return { matches: [], totalMatches: 0, truncated: false, error: output.stderr.trim() } + } + return { matches: [], totalMatches: 0, truncated: false } + } + + const jsonResult = createSgResultFromStdout(output.stdout) + if (!(shouldSeparateWritePass && jsonResult.matches.length > 0)) { + return jsonResult + } + + const writeArgs = buildSgArgs(options, { includeJson: false, includeUpdateAll: true }) + const writeResult = await trySpawn(binary, writeArgs, options.cwd, deps) + if (writeResult.error) { + return { ...jsonResult, error: `Replace failed: ${writeResult.error.error ?? "unknown error"}` } + } + + if (writeResult.value.exitCode !== 0) { + const errorDetail = + writeResult.value.stderr.trim() || `ast-grep exited with code ${writeResult.value.exitCode}` + return { ...jsonResult, error: `Replace failed: ${errorDetail}` } + } + + return jsonResult +} + +async function trySpawn( + binary: string, + args: readonly string[], + cwd: string | undefined, + deps: SgRunnerDeps, +): Promise<{ readonly value: SpawnResult; readonly error?: never } | { readonly value?: never; readonly error: SgResult }> { + try { + const value = await deps.spawnProcess(binary, args, { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + return { value } + } catch (error) { + if (error instanceof Error && error.message.includes("timeout")) { + return { + error: { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: "timeout", + error: error.message, + }, + } + } + + if (isNoEntryError(error)) { + return { + error: { + matches: [], + totalMatches: 0, + truncated: false, + error: SG_BINARY_NOT_FOUND_MESSAGE, + }, + } + } + + return { + error: { + matches: [], + totalMatches: 0, + truncated: false, + error: `Failed to spawn ast-grep: ${errorMessage(error)}`, + }, + } + } +} + +function isNoEntryError(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false + } + + const code = Reflect.get(error, "code") + const message = errorMessage(error) + return code === "ENOENT" || message.includes("ENOENT") || message.includes("not found") +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return String(error) +} + +export { DEFAULT_TIMEOUT_MS } diff --git a/packages/ast-grep-core/src/sg-compact-json-output.ts b/packages/ast-grep-core/src/sg-compact-json-output.ts new file mode 100644 index 000000000..a3fa9ae43 --- /dev/null +++ b/packages/ast-grep-core/src/sg-compact-json-output.ts @@ -0,0 +1,54 @@ +import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./language-support" +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/packages/ast-grep-core/src/types.ts b/packages/ast-grep-core/src/types.ts new file mode 100644 index 000000000..ee0e019f7 --- /dev/null +++ b/packages/ast-grep-core/src/types.ts @@ -0,0 +1,35 @@ +import type { CLI_LANGUAGES } from "./language-support" + +export type CliLanguage = (typeof CLI_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 SgResult { + matches: CliMatch[] + totalMatches: number + truncated: boolean + truncatedReason?: "max_matches" | "max_output_bytes" | "timeout" + error?: string +} diff --git a/packages/ast-grep-core/tsconfig.json b/packages/ast-grep-core/tsconfig.json new file mode 100644 index 000000000..079c808c1 --- /dev/null +++ b/packages/ast-grep-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext", "DOM"], + "types": ["bun-types"] + }, + "include": ["src/**/*"] +} diff --git a/packages/ast-grep-mcp/package.json b/packages/ast-grep-mcp/package.json index 286bf7925..f20bf7582 100644 --- a/packages/ast-grep-mcp/package.json +++ b/packages/ast-grep-mcp/package.json @@ -18,6 +18,7 @@ "test": "bun test src/*.test.ts" }, "dependencies": { + "@oh-my-opencode/ast-grep-core": "workspace:*", "@ast-grep/cli": "^0.41.1" }, "devDependencies": { diff --git a/packages/ast-grep-mcp/src/constants.ts b/packages/ast-grep-mcp/src/constants.ts index e535d0fe3..9e6876ed5 100644 --- a/packages/ast-grep-mcp/src/constants.ts +++ b/packages/ast-grep-mcp/src/constants.ts @@ -1,2 +1,7 @@ -export { CLI_LANGUAGES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support" +export { + CLI_LANGUAGES, + DEFAULT_TIMEOUT_MS, + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_MAX_MATCHES, +} from "@oh-my-opencode/ast-grep-core" export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path" diff --git a/packages/ast-grep-mcp/src/language-support.ts b/packages/ast-grep-mcp/src/language-support.ts index 52aa996c7..171b3c76b 100644 --- a/packages/ast-grep-mcp/src/language-support.ts +++ b/packages/ast-grep-mcp/src/language-support.ts @@ -1,31 +1,6 @@ -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 - -export const DEFAULT_TIMEOUT_MS = 300_000 -export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024 -export const DEFAULT_MAX_MATCHES = 500 +export { + CLI_LANGUAGES, + DEFAULT_MAX_MATCHES, + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_TIMEOUT_MS, +} from "@oh-my-opencode/ast-grep-core" diff --git a/packages/ast-grep-mcp/src/pattern-hints.ts b/packages/ast-grep-mcp/src/pattern-hints.ts index 8370b830e..078e1952f 100644 --- a/packages/ast-grep-mcp/src/pattern-hints.ts +++ b/packages/ast-grep-mcp/src/pattern-hints.ts @@ -1,63 +1,5 @@ -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) -} +export { + detectLanguageSpecificMistake, + detectRegexMisuse, + getPatternHint, +} from "@oh-my-opencode/ast-grep-core" diff --git a/packages/ast-grep-mcp/src/result-formatter.ts b/packages/ast-grep-mcp/src/result-formatter.ts index b43b451aa..5a468c27d 100644 --- a/packages/ast-grep-mcp/src/result-formatter.ts +++ b/packages/ast-grep-mcp/src/result-formatter.ts @@ -1,70 +1 @@ -import type { 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 { formatReplaceResult, formatSearchResult } from "@oh-my-opencode/ast-grep-core" diff --git a/packages/ast-grep-mcp/src/runner.ts b/packages/ast-grep-mcp/src/runner.ts index 462c115b1..9785a7480 100644 --- a/packages/ast-grep-mcp/src/runner.ts +++ b/packages/ast-grep-mcp/src/runner.ts @@ -1,184 +1,58 @@ -import { spawn } from "./bun-spawn-shim" -import { existsSync } from "fs" +import { existsSync } from "node:fs" import { - getSgCliPath, DEFAULT_TIMEOUT_MS, -} from "./constants" -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 { + runSg as runSgCore, + type SgResult, + type SgRunArgs, + type SpawnOptions, + type SpawnResult, +} from "@oh-my-opencode/ast-grep-core" +import { spawn } from "./bun-spawn-shim" +import { ensureCliAvailable, getAstGrepPath, isCliAvailable, startBackgroundInit, } from "./cli-binary-path-resolution" +import { getSgCliPath } from "./constants" +import { collectProcessOutputWithTimeout } from "./process-output-timeout" -export interface RunOptions { - pattern: string - lang: CliLanguage - cwd?: string - paths?: readonly string[] - globs?: readonly string[] - rewrite?: string - context?: number - updateAll?: boolean -} +export { ensureCliAvailable, getAstGrepPath, isCliAvailable, startBackgroundInit } + +export type RunOptions = SgRunArgs 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 = createSgArgs(options, { includeJson: true, includeUpdateAll: false }) - - let cliPath = getSgCliPath() - - if (!cliPath || !existsSync(cliPath)) { - const resolvedPath = await getAstGrepPath() - if (resolvedPath) { - cliPath = resolvedPath - } 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], { - cwd: options.cwd, - stdout: "pipe", - stderr: "pipe", + return runSgCore(options, { + resolveBinary: resolveBinaryPath, + spawnProcess, }) +} - 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 = errorCodeFrom(error) - const isNoEntry = - errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found") - - if (isNoEntry) { - return { - matches: [], - totalMatches: 0, - truncated: false, - error: - `ast-grep CLI 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`, - } - } - - return { - matches: [], - totalMatches: 0, - truncated: false, - error: `Failed to spawn ast-grep: ${errorMessage}`, - } +async function resolveBinaryPath(): Promise { + const cliPath = getSgCliPath() + if (cliPath && existsSync(cliPath)) { + return cliPath } - 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 = createSgArgs(options, { includeJson: false, includeUpdateAll: true }) - - const writeProc = spawn([cliPath, ...writeArgs], { - cwd: options.cwd, - 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 + const resolvedPath = await getAstGrepPath() + if (!resolvedPath) { + const noEntryError = new Error("ENOENT: ast-grep binary not found") + Reflect.set(noEntryError, "code", "ENOENT") + throw noEntryError + } + return resolvedPath } -function createSgArgs(options: RunOptions, flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean }): string[] { - const args = ["run", "-p", options.pattern, "--lang", options.lang] +async function spawnProcess( + binary: string, + args: readonly string[], + options?: SpawnOptions, +): Promise { + const proc = spawn([binary, ...args], { + cwd: options?.cwd, + stdout: options?.stdout ?? "pipe", + stderr: options?.stderr ?? "pipe", + }) - if (flags.includeJson) { - args.push("--json=compact") - } - - if (options.rewrite) { - args.push("-r", options.rewrite) - if (flags.includeUpdateAll) { - 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) - return args -} - -function errorCodeFrom(error: unknown): unknown { - if (typeof error !== "object" || error === null || !("code" in error)) return undefined - return Reflect.get(error, "code") + return collectProcessOutputWithTimeout(proc, DEFAULT_TIMEOUT_MS) } diff --git a/packages/ast-grep-mcp/src/sg-compact-json-output.ts b/packages/ast-grep-mcp/src/sg-compact-json-output.ts index 218253165..d708d3db1 100644 --- a/packages/ast-grep-mcp/src/sg-compact-json-output.ts +++ b/packages/ast-grep-mcp/src/sg-compact-json-output.ts @@ -1,54 +1 @@ -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, - } -} +export { createSgResultFromStdout } from "@oh-my-opencode/ast-grep-core" diff --git a/packages/ast-grep-mcp/src/types.ts b/packages/ast-grep-mcp/src/types.ts index 82b558903..615b3dd20 100644 --- a/packages/ast-grep-mcp/src/types.ts +++ b/packages/ast-grep-mcp/src/types.ts @@ -1,35 +1,7 @@ -import type { CLI_LANGUAGES } from "./constants" - -export type CliLanguage = (typeof CLI_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 SgResult { - matches: CliMatch[] - totalMatches: number - truncated: boolean - truncatedReason?: "max_matches" | "max_output_bytes" | "timeout" - error?: string -} +export type { + CliLanguage, + CliMatch, + Position, + Range, + SgResult, +} from "@oh-my-opencode/ast-grep-core"