diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f229a323c..9f8533968 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `glob` parameter to `ast_grep` and `ast_edit` tools for additional glob filtering relative to the `path` parameter @@ -8,6 +9,9 @@ ### Changed +- Renamed `patterns` parameter to `pat` in `ast_grep` tool for consistency +- Renamed `selector` parameter to `sel` in `ast_grep` and `ast_edit` tools for brevity +- Updated tool documentation with expanded guidance on AST pattern syntax, metavariable usage, and contextual matching strategies - Updated `grep` tool to combine glob patterns from `path` and `glob` parameters instead of throwing an error when both are provided ## [13.9.4] - 2026-03-07 diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index 17bca6b78..f6963e2e9 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -4,8 +4,13 @@ Performs structural AST-aware rewrites via native ast-grep. - Use for codemods and structural rewrites where plain text replace is unsafe - Narrow scope with `path` before replacing (`path` accepts files, directories, or glob patterns; use `glob` for an additional filter relative to `path`) - Default to language-scoped rewrites in mixed repositories: set `lang` and keep `path`/`glob` narrow -- Treat parse issues as a scoping signal: tighten `path`/`lang` before retrying +- Treat parse issues as a scoping or pattern-shape signal: tighten `path`/`lang`, or rewrite the pattern into valid syntax before retrying - Metavariables captured in each rewrite pattern (`$A`, `$$$ARGS`) are substituted into that entry's rewrite template +- For variadic captures, use `$$$NAME` (not `$$NAME`) +- Rewrite patterns must parse as valid AST for the target language; if a method or declaration does not parse standalone, wrap it in valid context or switch to a contextual `sel` +- For TypeScript declarations, prefer patterns that tolerate annotations you do not care about, e.g. `async function $NAME($$$ARGS): $_ { $$$BODY }` +- Metavariables must be the sole content of an AST node; partial-text metavariables like `prefix$VAR` or `"hello $NAME"` do NOT work in patterns or rewrites +- To delete matched code, use an empty `out` string: `{"pat":"console.log($$$)","out":""}` - Each matched rewrite is a 1:1 structural substitution; you cannot split one capture into multiple nodes or merge multiple captures into one node @@ -17,14 +22,25 @@ Performs structural AST-aware rewrites via native ast-grep. - Rename a call site across a directory: `{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"lang":"typescript","path":"src/"}` -- Multi-op codemod: - `{"ops":[{"pat":"require($A)","out":"import $A"},{"pat":"module.exports = $E","out":"export default $E"}],"lang":"javascript","path":"src/"}` +- Delete all matching calls (empty `out` removes the matched node): + `{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"lang":"typescript","path":"src/"}` +- Rewrite an import source path: + `{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"lang":"typescript","path":"src/"}` +- Modernize to optional chaining (same metavariable enforces identity): + `{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"lang":"typescript","path":"src/"}` - Swap two arguments using captures: `{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"lang":"typescript","path":"tests/"}` +- Rename a TypeScript function declaration while tolerating any return type annotation: + `{"ops":[{"pat":"async function fetchData($$$ARGS): $_ { $$$BODY }","out":"async function loadData($$$ARGS): $_ { $$$BODY }"}],"sel":"function_declaration","lang":"typescript","path":"src/api.ts"}` +- Rewrite a class method by matching it through valid class context: + `{"ops":[{"pat":"class $_ { execute($$$ARGS) { $$$BODY } }","out":"class $_ { run($$$ARGS) { $$$BODY } }"}],"sel":"method_definition","lang":"typescript","path":"src/runner.ts"}` +- Convert Python print calls to logging: + `{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"lang":"python","path":"src/"}` - `ops` **MUST** contain at least one concrete `{ pat, out }` entry - If the path pattern spans multiple languages, set `lang` explicitly for deterministic rewrites +- Parse issues mean the rewrite request is malformed or mis-scoped; do not assume a clean no-op until the pattern parses successfully - For one-off local text edits, prefer the Edit tool instead of AST edit \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index f41609f92..90a5f5974 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -4,12 +4,19 @@ Performs structural code search using AST matching via native ast-grep. - Use this when syntax shape matters more than raw text (calls, declarations, specific language constructs) - Prefer a precise `path` scope to keep results targeted and deterministic (`path` accepts files, directories, or glob patterns; use `glob` for an additional filter relative to `path`) - Default to language-scoped search in mixed repositories: pair `path` + `glob` + explicit `lang` to avoid parse-noise from non-source files -- `patterns` is required and must include at least one non-empty AST pattern; `lang` is optional (`lang` is inferred per file extension when omitted) +- `pat` is required and must include at least one non-empty AST pattern; `lang` is optional (`lang` is inferred per file extension when omitted) - Multiple patterns run in one native pass; results are merged and then `offset`/`limit` are applied to the combined match set -- Use `selector` only for contextual pattern mode; otherwise provide direct patterns +- Use `sel` only for contextual pattern mode; otherwise provide direct patterns - For variadic arguments/fields, use `$$$NAME` (not `$$NAME`) +- Patterns must parse as a single valid AST node for the target language; if a bare pattern fails, wrap it in valid context or use `sel` - Patterns match AST structure, not text — whitespace/formatting differences are ignored - When the same metavariable appears multiple times, all occurrences must match identical code +- For TypeScript declarations, prefer shapes that tolerate annotations you do not care about, e.g. `async function $NAME($$$ARGS): $_ { $$$BODY }` instead of omitting the return type entirely +- Metavariables must be the sole content of an AST node; partial-text metavariables like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` do NOT work — match the whole node instead +- `$$$` captures are lazy (non-greedy): they stop when the next element in the pattern can match; place the most specific node after `$$$` to control where capture ends +- `$_` is a non-capturing wildcard (matches any single node without binding); use it when you need to tolerate a node but don't need its value +- Search the right declaration form before concluding absence: top-level function, class method, and variable-assigned function are different AST shapes +- If you only need to prove a symbol exists, prefer a looser contextual search such as `pat: ["executeBash"]` with `sel: "identifier"` @@ -19,16 +26,27 @@ Performs structural code search using AST matching via native ast-grep. - Find all console logging calls in one pass (multi-pattern, scoped): - `{"patterns":["console.log($$$)","console.error($$$)"],"lang":"typescript","path":"src/"}` -- Capture and inspect metavariable bindings from a pattern: - `{"patterns":["require($MOD)"],"lang":"javascript","path":"src/"}` + `{"pat":["console.log($$$)","console.error($$$)"],"lang":"typescript","path":"src/"}` +- Find all named imports from a specific package: + `{"pat":["import { $$$IMPORTS } from \"react\""],"lang":"typescript","path":"src/"}` +- Match arrow functions assigned to a const (different AST shape than function declarations): + `{"pat":["const $NAME = ($$$ARGS) => $BODY"],"lang":"typescript","path":"src/utils/"}` +- Match any method call on an object using wildcard `$_` (ignores method name): + `{"pat":["logger.$_($$$ARGS)"],"lang":"typescript","path":"src/"}` - Contextual pattern with selector — match only the identifier `foo`, not the whole call: - `{"patterns":["foo()"],"selector":"identifier","lang":"typescript","path":"src/utils.ts"}` + `{"pat":["foo()"],"sel":"identifier","lang":"typescript","path":"src/utils.ts"}` +- Match a class method by wrapping it in valid class context and selecting the method node: + `{"pat":["class $_ { async handle($$$ARGS) { $$$BODY } }"],"sel":"method_definition","lang":"typescript","path":"src/server.ts"}` +- Match a TypeScript function declaration without caring about its exact return type: + `{"pat":["async function processItems($$$ARGS): $_ { $$$BODY }"],"sel":"function_declaration","lang":"typescript","path":"src/worker.ts"}` +- Loosest existence check for a symbol in one file: + `{"pat":["processItems"],"sel":"identifier","lang":"typescript","path":"src/worker.ts"}` -- `patterns` is required +- `pat` is required - Set `lang` explicitly to constrain matching when path pattern spans mixed-language trees - Avoid repo-root AST scans when the target is language-specific; narrow `path` first +- Treat parse issues as query failure, not evidence of absence: repair the pattern or tighten `path`/`glob`/`lang` before concluding "no matches" - If exploration is broad/open-ended across subsystems, use Task tool with explore subagent first \ No newline at end of file diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 2436cac37..993ae6726 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -39,7 +39,7 @@ const astEditSchema = Type.Object({ lang: Type.Optional(Type.String({ description: "Language override" })), path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })), glob: Type.Optional(Type.String({ description: "Optional glob filter relative to path" })), - selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })), + sel: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })), limit: Type.Optional(Type.Number({ description: "Max total replacements" })), }); @@ -134,7 +134,7 @@ export class AstEditTool implements AgentTool; lang?: string; path?: string; - selector?: string; + sel?: string; limit?: number; } diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 754c9b1c3..04a7ae1f1 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -28,11 +28,11 @@ import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; const astGrepSchema = Type.Object({ - patterns: Type.Array(Type.String(), { minItems: 1, description: "AST patterns to match" }), + pat: Type.Array(Type.String(), { minItems: 1, description: "AST patterns to match" }), lang: Type.Optional(Type.String({ description: "Language override" })), path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to search (default: cwd)" })), glob: Type.Optional(Type.String({ description: "Optional glob filter relative to path" })), - selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })), + sel: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })), limit: Type.Optional(Type.Number({ description: "Max matches (default: 50)" })), offset: Type.Optional(Type.Number({ description: "Skip first N matches (default: 0)" })), context: Type.Optional(Type.Number({ description: "Context lines around each match" })), @@ -69,11 +69,9 @@ export class AstGrepTool implements AgentTool> { return untilAborted(signal, async () => { - const patterns = [ - ...new Set(params.patterns.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0)), - ]; + const patterns = [...new Set(params.pat.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0))]; if (patterns.length === 0) { - throw new ToolError("`patterns` must include at least one non-empty pattern"); + throw new ToolError("`pat` must include at least one non-empty pattern"); } const limit = params.limit === undefined ? 50 : Math.floor(params.limit); if (!Number.isFinite(limit) || limit < 1) { @@ -127,7 +125,7 @@ export class AstGrepTool implements AgentTool 0) meta.push(`limit:${args.limit}`); if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`); if (args.context !== undefined) meta.push(`context:${args.context}`); - if (args.patterns && args.patterns.length > 1) meta.push(`${args.patterns.length} patterns`); + if (args.pat && args.pat.length > 1) meta.push(`${args.pat.length} patterns`); - const description = - args.patterns?.length === 1 ? args.patterns[0] : args.patterns ? `${args.patterns.length} patterns` : "?"; + const description = args.pat?.length === 1 ? args.pat[0] : args.pat ? `${args.pat.length} patterns` : "?"; const text = renderStatusLine({ icon: "pending", title: "AST Grep", description, meta }, uiTheme); return new Text(text, 0, 0); }, @@ -322,7 +319,7 @@ export const astGrepToolRenderer = { const limitReached = details?.limitReached ?? false; if (matchCount === 0) { - const description = args?.patterns?.length === 1 ? args.patterns[0] : undefined; + const description = args?.pat?.length === 1 ? args.pat[0] : undefined; const meta = ["0 matches"]; if (details?.scopePath) meta.push(`in ${details.scopePath}`); if (filesSearched > 0) meta.push(`searched ${filesSearched}`); @@ -345,7 +342,7 @@ export const astGrepToolRenderer = { if (details?.scopePath) meta.push(`in ${details.scopePath}`); meta.push(`searched ${filesSearched}`); if (limitReached) meta.push(uiTheme.fg("warning", "limit reached")); - const description = args?.patterns?.length === 1 ? args.patterns[0] : undefined; + const description = args?.pat?.length === 1 ? args.pat[0] : undefined; const header = renderStatusLine( { icon: limitReached ? "warning" : "success", title: "AST Grep", description, meta }, uiTheme, diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index 9464abf85..e4a88f93c 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -141,7 +141,9 @@ describe("ast_edit tool schema", () => { }); const text = previewResult.content.find(content => content.type === "text")?.text ?? ""; - const details = previewResult.details as { totalReplacements?: number; fileReplacements?: Array<{ path: string; count: number }> } | undefined; + const details = previewResult.details as + | { totalReplacements?: number; fileReplacements?: Array<{ path: string; count: number }> } + | undefined; expect(text).toContain("## └─ root.ts (1 replacement)"); expect(text).toContain("## └─ child.ts (1 replacement)"); @@ -162,8 +164,12 @@ describe("ast_edit tool schema", () => { expect(await Bun.file(path.join(sourceDir, "root.ts")).text()).toContain("modernWrap(rootValue, rootArg)"); expect(await Bun.file(path.join(nestedDir, "child.ts")).text()).toContain("modernWrap(childValue, childArg)"); - expect(await Bun.file(path.join(sourceDir, "ignore.js")).text()).toContain("legacyWrap(ignoreValue, ignoreArg)"); - expect(await Bun.file(path.join(tempDir, "outside.ts")).text()).toContain("legacyWrap(outsideValue, outsideArg)"); + expect(await Bun.file(path.join(sourceDir, "ignore.js")).text()).toContain( + "legacyWrap(ignoreValue, ignoreArg)", + ); + expect(await Bun.file(path.join(tempDir, "outside.ts")).text()).toContain( + "legacyWrap(outsideValue, outsideArg)", + ); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/coding-agent/test/tools/ast-grep.test.ts b/packages/coding-agent/test/tools/ast-grep.test.ts index 0bd0b2c30..45c212b95 100644 --- a/packages/coding-agent/test/tools/ast-grep.test.ts +++ b/packages/coding-agent/test/tools/ast-grep.test.ts @@ -28,7 +28,7 @@ describe("ast_grep parse errors", () => { expect(tool).toBeDefined(); const result = await tool!.execute("ast-grep-parse", { - patterns: ["someUnlikelyCall($A)", "anotherUnlikelyCall($A)"], + pat: ["someUnlikelyCall($A)", "anotherUnlikelyCall($A)"], lang: "typescript", path: filePath, }); @@ -64,8 +64,8 @@ describe("ast_grep parse errors", () => { expect(tool).toBeDefined(); const result = await tool!.execute("ast-grep-glob", { - patterns: ["providerOptions"], - selector: "identifier", + pat: ["providerOptions"], + sel: "identifier", lang: "typescript", path: `${packagesDir}/pkg-*/src`, glob: "**/*.ts", @@ -84,5 +84,4 @@ describe("ast_grep parse errors", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); - });