refactor(coding-agent): simplified AST tool parameters and expanded documentation

- Renamed parameter names in ast-grep and ast-edit tools from `patterns`/`selector` to `pat`/`sel` for brevity across schema, implementation, and tests.
- Expanded ast-grep and ast-edit tool documentation with 12+ new usage guidelines, examples, and critical notes on pattern syntax, metavariable placement, and error handling.
- Updated CHANGELOG.md to document parameter renames and expanded tool guidance for AST pattern syntax and metavariable usage.
- Reformatted test assertions and type annotations across ast-edit and ast-grep test files for improved readability.
This commit is contained in:
can1357
2026-03-08 03:08:38 +01:00
parent c3323160be
commit 72b036dcc0
7 changed files with 76 additions and 36 deletions
+4
View File
@@ -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
@@ -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
</instruction>
@@ -17,14 +22,25 @@ Performs structural AST-aware rewrites via native ast-grep.
<examples>
- 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/"}`
</examples>
<critical>
- `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
</critical>
@@ -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"`
</instruction>
<output>
@@ -19,16 +26,27 @@ Performs structural code search using AST matching via native ast-grep.
<examples>
- 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"}`
</examples>
<critical>
- `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
</critical>
+4 -4
View File
@@ -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<typeof astEditSchema, AstEditToolD
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.selector?.trim(),
selector: params.sel?.trim(),
dryRun: true,
maxReplacements,
maxFiles,
@@ -278,7 +278,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.selector?.trim(),
selector: params.sel?.trim(),
dryRun: false,
maxReplacements,
maxFiles,
@@ -321,7 +321,7 @@ interface AstEditRenderArgs {
ops?: Array<{ pat?: string; out?: string }>;
lang?: string;
path?: string;
selector?: string;
sel?: string;
limit?: number;
}
+12 -15
View File
@@ -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<typeof astGrepSchema, AstGrepToolD
_context?: AgentToolContext,
): Promise<AgentToolResult<AstGrepToolDetails>> {
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<typeof astGrepSchema, AstGrepToolD
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.selector?.trim(),
selector: params.sel?.trim(),
limit,
offset,
context,
@@ -274,10 +272,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
// =============================================================================
interface AstGrepRenderArgs {
patterns?: string[];
pat?: string[];
lang?: string;
path?: string;
selector?: string;
sel?: string;
limit?: number;
offset?: number;
context?: number;
@@ -291,14 +289,13 @@ export const astGrepToolRenderer = {
const meta: string[] = [];
if (args.lang) meta.push(`lang:${args.lang}`);
if (args.path) meta.push(`in ${args.path}`);
if (args.selector) meta.push("selector");
if (args.sel) meta.push("selector");
if (args.limit !== undefined && args.limit > 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,
@@ -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 });
}
@@ -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 });
}
});
});