diff --git a/src/tools/ast-grep/pattern-hints.test.ts b/src/tools/ast-grep/pattern-hints.test.ts index 1528dfecc..626a640eb 100644 --- a/src/tools/ast-grep/pattern-hints.test.ts +++ b/src/tools/ast-grep/pattern-hints.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, it } from "bun:test" import { detectLanguageSpecificMistake, diff --git a/src/tools/ast-grep/tool-descriptions.test.ts b/src/tools/ast-grep/tool-descriptions.test.ts new file mode 100644 index 000000000..4cd3835c0 --- /dev/null +++ b/src/tools/ast-grep/tool-descriptions.test.ts @@ -0,0 +1,171 @@ +/// + +import { describe, expect, it } from "bun:test" +import { + AST_GREP_REPLACE_DESCRIPTION, + AST_GREP_SEARCH_DESCRIPTION, + AST_GREP_SEARCH_PATTERN_PARAM, +} from "./tool-descriptions" + +describe("AST_GREP_SEARCH_DESCRIPTION", () => { + it("#given the description #when inspecting #then asserts it is NOT regex", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("NOT regex") + }) + + it("#given the description #when inspecting #then explains meta-variables $VAR and $$$", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("$VAR") + expect(description).toContain("$$$") + }) + + it("#given the description #when inspecting #then warns against regex alternation", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("alternation") + expect(description).toContain("|") + }) + + it("#given the description #when inspecting #then warns against regex wildcards", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain(".*") + expect(description).toContain("wildcards") + }) + + it("#given the description #when inspecting #then warns against regex escapes", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("\\w") + }) + + it("#given the description #when inspecting #then warns against character classes", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("[a-z]") + }) + + it("#given the description #when inspecting #then tells LLM to use grep as fallback", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description.toLowerCase()).toContain("grep") + }) + + it("#given the description #when showing Python example #then omits the trailing colon bug", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).not.toContain("def $FUNC($$$):") + expect(description).toContain("def $FUNC($$$)") + }) + + it("#given the description #when inspecting #then shows TypeScript example", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("typescript") + expect(description).toContain("function $NAME($$$) { $$$ }") + }) + + it("#given the description #when inspecting #then shows Go example", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("go") + expect(description).toContain("func $NAME($$$) { $$$ }") + }) + + it("#given the description #when inspecting #then shows Rust example", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description).toContain("rust") + expect(description).toContain("fn $NAME(") + }) + + it("#given the description #when measuring #then stays within a token-reasonable length", () => { + // given / when + const description = AST_GREP_SEARCH_DESCRIPTION + + // then + expect(description.length).toBeLessThan(2000) + expect(description.length).toBeGreaterThan(400) + }) +}) + +describe("AST_GREP_SEARCH_PATTERN_PARAM", () => { + it("#given the param description #when inspecting #then states meta-var rules", () => { + // given / when + const description = AST_GREP_SEARCH_PATTERN_PARAM + + // then + expect(description).toContain("$VAR") + expect(description).toContain("$$$") + }) + + it("#given the param description #when inspecting #then forbids regex syntax", () => { + // given / when + const description = AST_GREP_SEARCH_PATTERN_PARAM + + // then + expect(description).toContain("NOT regex") + expect(description).toContain("|") + expect(description).toContain(".*") + }) + + it("#given the param description #when inspecting #then directs to grep for fallback", () => { + // given / when + const description = AST_GREP_SEARCH_PATTERN_PARAM + + // then + expect(description.toLowerCase()).toContain("grep") + }) +}) + +describe("AST_GREP_REPLACE_DESCRIPTION", () => { + it("#given the description #when inspecting #then mentions AST meta-variables", () => { + // given / when + const description = AST_GREP_REPLACE_DESCRIPTION + + // then + expect(description).toContain("$VAR") + expect(description).toContain("$$$") + }) + + it("#given the description #when inspecting #then warns against regex", () => { + // given / when + const description = AST_GREP_REPLACE_DESCRIPTION + + // then + expect(description.toLowerCase()).toContain("regex does not work") + }) + + it("#given the description #when inspecting #then provides an example", () => { + // given / when + const description = AST_GREP_REPLACE_DESCRIPTION + + // then + expect(description).toContain("console.log($MSG)") + expect(description).toContain("logger.info($MSG)") + }) +}) diff --git a/src/tools/ast-grep/tool-descriptions.ts b/src/tools/ast-grep/tool-descriptions.ts new file mode 100644 index 000000000..c15872e47 --- /dev/null +++ b/src/tools/ast-grep/tool-descriptions.ts @@ -0,0 +1,35 @@ +export const AST_GREP_SEARCH_DESCRIPTION = [ + "Search code by AST structure (25 languages). This is NOT regex.", + "", + "Meta-variables (the only wildcards ast-grep understands):", + " $VAR - one AST node (an identifier, expression, statement, ...)", + " $$$ - zero or more nodes (argument lists, function bodies, ...)", + " $$$VAR - same, captured by name", + "Patterns must be complete, parseable source code. Each meta-variable replaces a whole node, not a substring.", + "", + "Regex syntax does NOT work - never pass these to pattern:", + ' "foo|bar" alternation → run separate calls, or switch to grep', + ' ".*", ".+" wildcards → use $$$ between AST fragments', + ' "\\w", "\\d" escapes → use $VAR to capture any identifier', + ' "[a-z]" class ranges → no AST equivalent', + "For text search, cross-language search, or regex features, use the grep tool instead.", + "", + "Examples by language:", + ' typescript/tsx "function $NAME($$$) { $$$ }", "console.log($$$)", "import { $$$ } from \'$MOD\'"', + ' python "def $FUNC($$$)", "class $C($$$)" - no trailing colon', + ' go "func $NAME($$$) { $$$ }", "if err != nil { $$$ }"', + ' rust "fn $NAME($$$) -> $RET { $$$ }", "impl $TRAIT for $T { $$$ }"', + "", + "On empty results the tool returns a hint naming the exact mistake. If the pattern is fundamentally text-shaped, stop retrying and switch to grep.", +].join("\n") + +export const AST_GREP_SEARCH_PATTERN_PARAM = + "AST pattern - valid, parseable code using $VAR (one node) and $$$ (many nodes). NOT regex: no `|`, no `.*`, no `\\w`, no `[a-z]`. For text or alternation, use grep instead." + +export const AST_GREP_REPLACE_DESCRIPTION = [ + "Rewrite code by AST pattern (25 languages). Dry-run by default.", + "Both pattern and rewrite use AST syntax ($VAR for one node, $$$ for many) - regex does NOT work.", + "Meta-variables captured in pattern can be reused in rewrite to preserve matched content.", + 'Example: pattern="console.log($MSG)" rewrite="logger.info($MSG)"', + "For text-only replacement or regex features, use a text editor instead.", +].join("\n") diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts index 317e01a9a..2a2454fd2 100644 --- a/src/tools/ast-grep/tools.ts +++ b/src/tools/ast-grep/tools.ts @@ -4,6 +4,11 @@ import { CLI_LANGUAGES } from "./constants" import { runSg } from "./cli" import { formatSearchResult, formatReplaceResult } from "./result-formatter" import { getPatternHint } from "./pattern-hints" +import { + AST_GREP_REPLACE_DESCRIPTION, + AST_GREP_SEARCH_DESCRIPTION, + AST_GREP_SEARCH_PATTERN_PARAM, +} from "./tool-descriptions" import type { CliLanguage } from "./types" async function showOutputToUser(context: unknown, output: string): Promise { @@ -15,14 +20,9 @@ async function showOutputToUser(context: unknown, output: string): Promise export function createAstGrepTools(ctx: PluginInput): Record { const ast_grep_search: ToolDefinition = tool({ - description: - "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " + - "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " + - "IMPORTANT: Patterns must be complete AST nodes (valid code). " + - "For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " + - "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'", + description: AST_GREP_SEARCH_DESCRIPTION, args: { - pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."), + pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM), lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"), globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"), @@ -58,10 +58,7 @@ export function createAstGrepTools(ctx: PluginInput): Record