Skip to content

Commit 46ee26f

Browse files
committed
fix(ast-grep): address code review issues in error handling and documentation
- Fix silent failures in error handling: - Add error logging when JSON parsing fails (cli.ts) - Add error logging in binary verification (downloader.ts) - Add warning when reading version marker fails (downloader.ts) - Improve error handling robustness: - Use .finally() instead of .then() for timeout cleanup (cli.ts) - Include error details in nested catch blocks (cli.ts) - Add retry counter to prevent infinite download loops (cli.ts) - Return exit code when process exits with no output (cli.ts) - Log full error on NAPI load failure (napi.ts) - Security improvements: - Escape single quotes in PowerShell extractZip command - Use -LiteralPath for safer path handling - Better error messages: - Add Zod-specific error formatting for validation errors (index.ts) - Documentation updates: - Update ADR to document all 4 tools (search, replace, analyze, transform) - Update agent skill frontmatter with correct MCP tool names - Add NAPI tool documentation (ast_grep_analyze, ast_grep_transform)
1 parent a374f68 commit 46ee26f

6 files changed

Lines changed: 119 additions & 44 deletions

File tree

agents/ast-grep.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ description: |
4040
Pattern debugging - common mistake with Python syntax.
4141
</commentary>
4242
</example>
43-
tools: Bash, Read, Write
43+
tools: ast_grep_search, ast_grep_replace, ast_grep_analyze, ast_grep_transform
4444
model: sonnet
4545
---
4646

@@ -289,6 +289,38 @@ Transform code (dry-run by default):
289289
}
290290
```
291291

292+
### ast_grep_analyze (NAPI - In-Memory)
293+
294+
Analyze code in-memory without file I/O (faster, supports 5 languages: html, javascript, tsx, css, typescript):
295+
296+
```json
297+
{
298+
"code": "console.log('hello'); console.log('world');",
299+
"pattern": "console.log($MSG)",
300+
"lang": "javascript",
301+
"extractMetaVars": true
302+
}
303+
```
304+
305+
Returns matches with optional meta-variable extraction. Use for single-file analysis or quick pattern testing.
306+
307+
### ast_grep_transform (NAPI - In-Memory)
308+
309+
Transform code in-memory without modifying files (supports 5 languages: html, javascript, tsx, css, typescript):
310+
311+
```json
312+
{
313+
"code": "console.log('hello');",
314+
"pattern": "console.log($MSG)",
315+
"rewrite": "logger.info($MSG)",
316+
"lang": "javascript"
317+
}
318+
```
319+
320+
Returns transformed code without writing to disk. Use for previewing transformations or processing code strings.
321+
322+
**Note:** NAPI tools require `@ast-grep/napi` optional dependency. If not installed, use CLI-based `ast_grep_search` and `ast_grep_replace` instead.
323+
292324
---
293325

294326
## WORKFLOW

docs/adr/0001-ast-grep-integration-architecture.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ We will implement **both MCP Tools and an Agent/Skill** for ast-grep integration
2727

2828
Create a new `ast-grep` provider in `packages/dora/src/providers/ast-grep/` with:
2929

30-
| Tool | Purpose |
31-
|------|---------|
32-
| `ast_grep_search` | Pattern-based code search across files |
33-
| `ast_grep_replace` | AST-aware code transformation (dry-run by default) |
30+
| Tool | Purpose | Backend |
31+
|------|---------|---------|
32+
| `ast_grep_search` | Pattern-based code search across files | CLI (25 languages) |
33+
| `ast_grep_replace` | AST-aware code transformation (dry-run by default) | CLI (25 languages) |
34+
| `ast_grep_analyze` | In-memory code analysis with meta-variable extraction | NAPI (5 languages) |
35+
| `ast_grep_transform` | In-memory code transformation (no file I/O) | NAPI (5 languages) |
3436

3537
**Features:**
3638
- Both inline patterns (`console.log($MSG)`) and YAML rule files (`--rule file.yaml`)
@@ -69,10 +71,11 @@ agents/
6971
### Binary Management
7072

7173
Follow the Dart LSP pattern:
72-
1. Check system PATH first (`Bun.which('sg')`)
74+
1. Check system PATH first (`Bun.which('ast-grep')` or `Bun.which('sg')`)
7375
2. If not found, download to `~/.cache/dora/ast-grep/`
7476
3. Platform-specific binaries (win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64)
7577
4. Version tracking via marker file
78+
5. Binary verification to ensure it's actually ast-grep (checks `--version` output)
7679

7780
## Consequences
7881

packages/dora/src/providers/ast-grep/cli.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ export async function isCliAvailable(): Promise<boolean> {
4444

4545
/**
4646
* Run ast-grep CLI command
47+
*
48+
* @param options - Command options
49+
* @param retried - Internal flag to prevent infinite retry loops
4750
*/
48-
export async function runSg(options: RunSgOptions): Promise<SgResult> {
51+
export async function runSg(options: RunSgOptions, retried = false): Promise<SgResult> {
4952
const cliPath = await getAstGrepPath()
5053

5154
if (!cliPath) {
@@ -117,7 +120,8 @@ export async function runSg(options: RunSgOptions): Promise<SgResult> {
117120
proc.kill()
118121
reject(new Error(`Search timeout after ${timeout}ms`))
119122
}, timeout)
120-
proc.exited.then(() => clearTimeout(id))
123+
// Use .finally() to ensure cleanup even if proc.exited rejects
124+
proc.exited.finally(() => clearTimeout(id))
121125
})
122126

123127
let stdout: string
@@ -147,20 +151,20 @@ export async function runSg(options: RunSgOptions): Promise<SgResult> {
147151
|| nodeError.message?.includes('ENOENT')
148152
|| nodeError.message?.includes('not found')
149153
) {
150-
// Binary not found, try to download
151-
const downloadedPath = await ensureAstGrepBinary()
152-
if (downloadedPath) {
153-
resolvedCliPath = downloadedPath
154-
return runSg(options) // Retry
155-
}
156-
else {
157-
return {
158-
matches: [],
159-
totalMatches: 0,
160-
truncated: false,
161-
error: getInstallInstructions(),
154+
// Binary not found, try to download (only once to prevent infinite loops)
155+
if (!retried) {
156+
const downloadedPath = await ensureAstGrepBinary()
157+
if (downloadedPath) {
158+
resolvedCliPath = downloadedPath
159+
return runSg(options, true) // Retry once
162160
}
163161
}
162+
return {
163+
matches: [],
164+
totalMatches: 0,
165+
truncated: false,
166+
error: getInstallInstructions(),
167+
}
164168
}
165169

166170
return {
@@ -179,7 +183,13 @@ export async function runSg(options: RunSgOptions): Promise<SgResult> {
179183
if (stderr.trim()) {
180184
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
181185
}
182-
return { matches: [], totalMatches: 0, truncated: false }
186+
// Non-zero exit with no output - include exit code for diagnosis
187+
return {
188+
matches: [],
189+
totalMatches: 0,
190+
truncated: false,
191+
error: `ast-grep exited with code ${exitCode} (no output)`,
192+
}
183193
}
184194

185195
// No output
@@ -196,7 +206,7 @@ export async function runSg(options: RunSgOptions): Promise<SgResult> {
196206
try {
197207
matches = JSON.parse(outputToProcess) as CliMatch[]
198208
}
199-
catch {
209+
catch (parseError) {
200210
if (outputTruncated) {
201211
// Try to parse partial JSON
202212
try {
@@ -209,18 +219,27 @@ export async function runSg(options: RunSgOptions): Promise<SgResult> {
209219
}
210220
}
211221
}
212-
catch {
222+
catch (recoveryError) {
223+
const errorMsg = recoveryError instanceof Error ? recoveryError.message : 'unknown'
213224
return {
214225
matches: [],
215226
totalMatches: 0,
216227
truncated: true,
217228
truncatedReason: 'max_output_bytes',
218-
error: 'Output too large and could not be parsed',
229+
error: `Output too large and could not be parsed: ${errorMsg}`,
219230
}
220231
}
221232
}
222233
else {
223-
return { matches: [], totalMatches: 0, truncated: false }
234+
// Non-truncated output but failed to parse - log and return error
235+
const errorMsg = parseError instanceof Error ? parseError.message : 'unknown'
236+
console.error(`[ast-grep] Failed to parse output: ${errorMsg}`)
237+
return {
238+
matches: [],
239+
totalMatches: 0,
240+
truncated: false,
241+
error: `Failed to parse ast-grep output: ${errorMsg}`,
242+
}
224243
}
225244
}
226245

packages/dora/src/providers/ast-grep/downloader.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ async function verifyAstGrepBinary(binaryPath: string): Promise<boolean> {
3232
// ast-grep version output contains "ast-grep" or version number like "0.x.x"
3333
return stdout.includes('ast-grep') || /^\d+\.\d+\.\d+/.test(stdout.trim())
3434
}
35-
catch {
35+
catch (e) {
36+
console.error(`[ast-grep] Binary verification failed for ${binaryPath}: ${e instanceof Error ? e.message : String(e)}`)
3637
return false
3738
}
3839
}
@@ -77,8 +78,9 @@ export function getCachedBinary(): string | null {
7778
return null
7879
}
7980
}
80-
catch {
81-
// Can't read version, assume outdated
81+
catch (e) {
82+
// Log warning but continue - will trigger re-download
83+
console.warn(`[ast-grep] Failed to read version marker at ${versionPath}: ${e instanceof Error ? e.message : String(e)}`)
8284
return null
8385
}
8486
}
@@ -90,17 +92,23 @@ export function getCachedBinary(): string | null {
9092
* Extract zip archive
9193
*/
9294
async function extractZip(archivePath: string, destDir: string): Promise<void> {
93-
const proc
94-
= process.platform === 'win32'
95-
? spawn(
96-
[
97-
'powershell',
98-
'-command',
99-
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
100-
],
101-
{ stdout: 'pipe', stderr: 'pipe' },
102-
)
103-
: spawn(['unzip', '-o', archivePath, '-d', destDir], { stdout: 'pipe', stderr: 'pipe' })
95+
let proc
96+
if (process.platform === 'win32') {
97+
// Escape single quotes for PowerShell by doubling them
98+
const escapedArchive = archivePath.replace(/'/g, "''")
99+
const escapedDest = destDir.replace(/'/g, "''")
100+
proc = spawn(
101+
[
102+
'powershell',
103+
'-command',
104+
`Expand-Archive -LiteralPath '${escapedArchive}' -DestinationPath '${escapedDest}' -Force`,
105+
],
106+
{ stdout: 'pipe', stderr: 'pipe' },
107+
)
108+
}
109+
else {
110+
proc = spawn(['unzip', '-o', archivePath, '-d', destDir], { stdout: 'pipe', stderr: 'pipe' })
111+
}
104112

105113
const exitCode = await proc.exited
106114

packages/dora/src/providers/ast-grep/index.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Provides AST-aware code search and transformation tools
55
*/
66

7-
import { z } from 'zod'
7+
import { z, ZodError } from 'zod'
88
import type { Provider, ToolDefinition, ToolResult } from '../provider'
99
import type { RegistryConfig } from '../registry'
1010
import { CLI_LANGUAGES, NAPI_LANGUAGES } from './constants'
@@ -13,6 +13,17 @@ import { isNapiAvailable, getNapiError, analyzeCode, transformCode } from './nap
1313
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult, getEmptyResultHint } from './utils'
1414
import type { NapiLanguage } from './types'
1515

16+
/**
17+
* Format error for tool result, with special handling for Zod validation errors
18+
*/
19+
function formatToolError(e: unknown): string {
20+
if (e instanceof ZodError) {
21+
const issues = e.issues.map(i => ` - ${i.path.join('.')}: ${i.message}`).join('\n')
22+
return `Invalid arguments:\n${issues}`
23+
}
24+
return `Error: ${e instanceof Error ? e.message : String(e)}`
25+
}
26+
1627
// Tool definitions
1728
const AST_GREP_TOOLS: ToolDefinition[] = [
1829
{
@@ -228,7 +239,7 @@ export class AstGrepProvider implements Provider {
228239
}
229240
catch (e) {
230241
return {
231-
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
242+
content: [{ type: 'text', text: formatToolError(e) }],
232243
isError: true,
233244
}
234245
}
@@ -267,7 +278,7 @@ export class AstGrepProvider implements Provider {
267278
}
268279
catch (e) {
269280
return {
270-
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
281+
content: [{ type: 'text', text: formatToolError(e) }],
271282
isError: true,
272283
}
273284
}
@@ -312,7 +323,7 @@ export class AstGrepProvider implements Provider {
312323
}
313324
catch (e) {
314325
return {
315-
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
326+
content: [{ type: 'text', text: formatToolError(e) }],
316327
isError: true,
317328
}
318329
}
@@ -357,7 +368,7 @@ export class AstGrepProvider implements Provider {
357368
}
358369
catch (e) {
359370
return {
360-
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
371+
content: [{ type: 'text', text: formatToolError(e) }],
361372
isError: true,
362373
}
363374
}

packages/dora/src/providers/ast-grep/napi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export function isNapiAvailable(): boolean {
3131
}
3232
catch (e) {
3333
napiLoadError = e instanceof Error ? e : new Error(String(e))
34+
// Log full error on first load failure for diagnostics
35+
console.error('[ast-grep] NAPI module load failed:', e)
3436
return false
3537
}
3638
}

0 commit comments

Comments
 (0)