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.
This commit is contained in:
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
|||||||
import { CLI_LANGUAGES } from "./constants"
|
import { CLI_LANGUAGES } from "./constants"
|
||||||
import { runSg } from "./cli"
|
import { runSg } from "./cli"
|
||||||
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
||||||
|
import { getPatternHint } from "./pattern-hints"
|
||||||
import type { CliLanguage } from "./types"
|
import type { CliLanguage } from "./types"
|
||||||
|
|
||||||
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
||||||
@@ -12,29 +13,6 @@ async function showOutputToUser(context: unknown, output: string): Promise<void>
|
|||||||
await ctx.metadata?.({ metadata: { output } })
|
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<string, ToolDefinition> {
|
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||||
const ast_grep_search: ToolDefinition = tool({
|
const ast_grep_search: ToolDefinition = tool({
|
||||||
description:
|
description:
|
||||||
@@ -63,7 +41,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
|
|||||||
let output = formatSearchResult(result)
|
let output = formatSearchResult(result)
|
||||||
|
|
||||||
if (result.matches.length === 0 && !result.error) {
|
if (result.matches.length === 0 && !result.error) {
|
||||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
|
||||||
if (hint) {
|
if (hint) {
|
||||||
output += `\n\n${hint}`
|
output += `\n\n${hint}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user