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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user