From 95ccbbfe8cbdd6967562e6f1524385ee58fb0fc2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 22 Apr 2026 12:23:51 +0900 Subject: [PATCH] feat(ast-grep): detect regex-style pattern misuse before returning empty results LLMs frequently call ast_grep_search with regex-style patterns like 'func.*build|BuildMode|projectReferences' instead of AST patterns. The search silently returns zero matches with no useful feedback, so the model retries with a different regex-shaped pattern and loops. Extract hint generation into pattern-hints.ts and add detectors for the four dominant misuse modes: - regex escapes (\\w, \\d, \\s, \\b) - character-class ranges ([a-z], [0-9]) - regex wildcards (.* .+) with no meta-vars - pure alternation (foo|bar|baz with no structural syntax) Heuristics are designed to be safe on valid AST patterns: bitwise OR '$A | $B' and Rust closures '|x| x + 1' are not flagged. Language- specific shape hints (trailing-colon Python, body-less JS/TS/Go/Rust functions) are preserved and extended to Go and Rust. --- src/tools/ast-grep/pattern-hints.test.ts | 297 +++++++++++++++++++++++ src/tools/ast-grep/pattern-hints.ts | 63 +++++ src/tools/ast-grep/tools.ts | 26 +- 3 files changed, 362 insertions(+), 24 deletions(-) create mode 100644 src/tools/ast-grep/pattern-hints.test.ts create mode 100644 src/tools/ast-grep/pattern-hints.ts diff --git a/src/tools/ast-grep/pattern-hints.test.ts b/src/tools/ast-grep/pattern-hints.test.ts new file mode 100644 index 000000000..1528dfecc --- /dev/null +++ b/src/tools/ast-grep/pattern-hints.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from "bun:test" +import { + detectLanguageSpecificMistake, + detectRegexMisuse, + getPatternHint, +} from "./pattern-hints" + +describe("detectRegexMisuse", () => { + describe("#given pure regex alternation", () => { + it("#when pattern is lowercase alternation #then returns alternation hint", () => { + // given + const pattern = "watch|WatchMode|--watch" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).not.toBeNull() + expect(hint).toContain("|") + expect(hint).toContain("alternation") + expect(hint).toContain("grep") + }) + + it("#when pattern is camelCase alternation #then returns alternation hint", () => { + // given + const pattern = "noEmit|NoEmit" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toContain("alternation") + }) + + it("#when pattern mixes wildcard and alternation #then returns a hint", () => { + // given + const pattern = "func.*build|BuildMode|projectReferences" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).not.toBeNull() + }) + }) + + describe("#given valid AST patterns using |", () => { + it("#when pattern uses meta-vars around pipe (bitwise OR) #then returns null", () => { + // given + const pattern = "$A | $B" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + + it("#when pattern is a Rust closure #then returns null", () => { + // given + const pattern = "|x| x + 1" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + }) + + describe("#given regex escape sequences", () => { + it("#when pattern contains \\w #then returns regex-escape hint", () => { + // given + const pattern = "\\w+Mode" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toContain("regex escape") + expect(hint).toContain("grep") + }) + + it("#when pattern contains \\d #then returns regex-escape hint", () => { + // given + const pattern = "id\\d+" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toContain("regex escape") + }) + }) + + describe("#given character class ranges", () => { + it("#when pattern contains [a-z] #then returns character-class hint", () => { + // given + const pattern = "[a-z]+Mode" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toContain("character classes") + expect(hint).toContain("grep") + }) + + it("#when pattern contains [0-9] #then returns character-class hint", () => { + // given + const pattern = "v[0-9]+" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toContain("character classes") + }) + }) + + describe("#given regex wildcards embedded in identifiers", () => { + it("#when pattern uses foo.*bar without meta-vars #then returns wildcard hint", () => { + // given + const pattern = "func.*build" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toContain("regex wildcards") + expect(hint).toContain("$$$") + }) + + it("#when pattern uses $$$ (proper AST) #then returns null", () => { + // given + const pattern = "func $NAME($$$) { $$$ }" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + }) + + describe("#given legitimate AST patterns", () => { + it("#when pattern is a JS function #then returns null", () => { + // given + const pattern = "function $NAME($$$) { $$$ }" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + + it("#when pattern is console.log call #then returns null", () => { + // given + const pattern = "console.log($$$)" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + + it("#when pattern is a Python def #then returns null", () => { + // given + const pattern = "def $FUNC($$$)" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + + it("#when pattern is array access a[0] #then returns null (not character class)", () => { + // given + const pattern = "$A[0]" + + // when + const hint = detectRegexMisuse(pattern) + + // then + expect(hint).toBeNull() + }) + }) +}) + +describe("detectLanguageSpecificMistake", () => { + describe("#given a Python def with trailing colon", () => { + it("#when lang is python #then suggests removing the colon", () => { + // given + const pattern = "def $FUNC($$$):" + + // when + const hint = detectLanguageSpecificMistake(pattern, "python") + + // then + expect(hint).toContain("Remove trailing colon") + expect(hint).toContain("def $FUNC($$$)") + }) + }) + + describe("#given a Python class with trailing colon", () => { + it("#when lang is python #then suggests removing the colon", () => { + // given + const pattern = "class $C:" + + // when + const hint = detectLanguageSpecificMistake(pattern, "python") + + // then + expect(hint).toContain("Remove trailing colon") + }) + }) + + describe("#given a TypeScript function with no body", () => { + it("#when lang is typescript #then suggests adding params and body", () => { + // given + const pattern = "function $NAME" + + // when + const hint = detectLanguageSpecificMistake(pattern, "typescript") + + // then + expect(hint).toContain("params and body") + expect(hint).toContain("function $NAME($$$) { $$$ }") + }) + }) + + describe("#given a Go function with no body", () => { + it("#when lang is go #then suggests Go function template", () => { + // given + const pattern = "func $NAME" + + // when + const hint = detectLanguageSpecificMistake(pattern, "go") + + // then + expect(hint).not.toBeNull() + expect(hint).toContain("func $NAME($$$) { $$$ }") + }) + }) + + describe("#given a Rust fn with no body", () => { + it("#when lang is rust #then suggests Rust fn template", () => { + // given + const pattern = "fn $NAME" + + // when + const hint = detectLanguageSpecificMistake(pattern, "rust") + + // then + expect(hint).not.toBeNull() + expect(hint).toContain("fn $NAME($$$) { $$$ }") + }) + }) +}) + +describe("getPatternHint", () => { + it("#given regex alternation #when composing #then regex hint wins over language check", () => { + // given + const pattern = "foo|bar" + + // when + const hint = getPatternHint(pattern, "typescript") + + // then + expect(hint).toContain("alternation") + }) + + it("#given a clean AST pattern #when composing #then returns null", () => { + // given + const pattern = "function $NAME($$$) { $$$ }" + + // when + const hint = getPatternHint(pattern, "typescript") + + // then + expect(hint).toBeNull() + }) + + it("#given a Python def with trailing colon #when composing #then returns the colon hint", () => { + // given + const pattern = "def $FUNC($$$):" + + // when + const hint = getPatternHint(pattern, "python") + + // then + expect(hint).toContain("Remove trailing colon") + }) +}) diff --git a/src/tools/ast-grep/pattern-hints.ts b/src/tools/ast-grep/pattern-hints.ts new file mode 100644 index 000000000..8370b830e --- /dev/null +++ b/src/tools/ast-grep/pattern-hints.ts @@ -0,0 +1,63 @@ +import type { CliLanguage } from "./types" + +export function detectRegexMisuse(pattern: string): string | null { + const src = pattern.trim() + + if (/\\[wWdDsSbB]/.test(src)) { + return 'Hint: "\\w", "\\d", "\\s", "\\b" are regex escapes. ast-grep matches AST nodes, not text - use $VAR for identifiers, $$$ for node lists, or switch to grep for text search.' + } + + if (/\[[a-zA-Z0-9]-[a-zA-Z0-9]\]/.test(src)) { + return 'Hint: "[a-z]" and similar character classes are regex, not AST. Use $VAR to match any identifier, or switch to grep for text search.' + } + + if (!src.includes("$") && /\w\.[*+]/.test(src)) { + return 'Hint: ".*" and ".+" are regex wildcards. In ast-grep use $$$ for multiple AST nodes and $VAR for a single node. For text patterns, switch to grep.' + } + + if (/^[-\w.*]+\|[-\w.*|]+$/.test(src)) { + return 'Hint: "|" is regex alternation and does NOT work in ast-grep patterns. Options: (a) fire one ast_grep_search per alternative, or (b) switch to grep with a regex pattern like "foo|bar".' + } + + return null +} + +export function detectLanguageSpecificMistake( + pattern: string, + lang: CliLanguage, +): string | null { + const src = pattern.trim() + + if (lang === "python") { + if (src.startsWith("class ") && src.endsWith(":")) { + return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"` + } + if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) { + return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"` + } + } + + if (["javascript", "typescript", "tsx"].includes(lang)) { + if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { + return 'Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"' + } + } + + if (lang === "go") { + if (/^func\s+\$[A-Z_]+\s*$/i.test(src)) { + return 'Hint: Go function patterns need params and body. Try "func $NAME($$$) { $$$ }"' + } + } + + if (lang === "rust") { + if (/^fn\s+\$[A-Z_]+\s*$/i.test(src)) { + return 'Hint: Rust fn patterns need params and body. Try "fn $NAME($$$) { $$$ }"' + } + } + + return null +} + +export function getPatternHint(pattern: string, lang: CliLanguage): string | null { + return detectRegexMisuse(pattern) ?? detectLanguageSpecificMistake(pattern, lang) +} diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts index 98b2d0c7e..317e01a9a 100644 --- a/src/tools/ast-grep/tools.ts +++ b/src/tools/ast-grep/tools.ts @@ -3,6 +3,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { CLI_LANGUAGES } from "./constants" import { runSg } from "./cli" import { formatSearchResult, formatReplaceResult } from "./result-formatter" +import { getPatternHint } from "./pattern-hints" import type { CliLanguage } from "./types" async function showOutputToUser(context: unknown, output: string): Promise { @@ -12,29 +13,6 @@ async function showOutputToUser(context: unknown, output: string): Promise await ctx.metadata?.({ metadata: { output } }) } -function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null { - const src = pattern.trim() - - if (lang === "python") { - if (src.startsWith("class ") && src.endsWith(":")) { - const withoutColon = src.slice(0, -1) - return `Hint: Remove trailing colon. Try: "${withoutColon}"` - } - if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) { - const withoutColon = src.slice(0, -1) - return `Hint: Remove trailing colon. Try: "${withoutColon}"` - } - } - - if (["javascript", "typescript", "tsx"].includes(lang)) { - if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { - return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"` - } - } - - return null -} - export function createAstGrepTools(ctx: PluginInput): Record { const ast_grep_search: ToolDefinition = tool({ description: @@ -63,7 +41,7 @@ export function createAstGrepTools(ctx: PluginInput): Record