feat(coding-agent): added unified path targets for grep family

- Consolidated grep, ast-grep, and ast-edit on required `path`, replacing `glob`/`lang`/`sel` with inline file, dir, glob, list, and URL targets.
- Changed ast-grep and grep schemas to require a single `pat` string and use `skip` pagination instead of array patterns or offsets.
- Updated argument validation to reject empty `path` and invalid `skip`, and routed grep context to session settings only.
- Updated tool prompts and tests to reflect new path globbing semantics and `first N` truncation output text.
This commit is contained in:
can1357
2026-04-26 04:33:04 +02:00
parent 44ea247cf8
commit b22837f898
15 changed files with 210 additions and 336 deletions
+7
View File
@@ -1,8 +1,12 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed multi-pattern array input from `ast_grep` by changing `pat` to a single pattern string, so call sites using `pat: [...]` must be updated to send one query per invocation
- Removed `lang`, `glob`, and `sel` options from `ast_edit` and `ast_grep`, and moved those behaviors into the required `path` argument
- Required `path` for `ast_edit` and `ast_grep`, so invocations that relied on implicit repo-root searching are no longer valid
- Renamed atom edit operations from `before` and `after` to `pre` and `post`, so existing `atom` payloads using the old operation keys must be updated
- Changed the hashline anchor format from `LINE#ID:content` to `LINEID\tcontent` (no `#`/`:` separators, tab between anchor and content, no padding on line numbers); expanded the bigram alphabet from 40 hand-picked English bigrams to the full 647 single-token 2-letter bigrams — invalidates every previously captured `LINE#ID` reference
- Renamed the subagent completion contract from `submit_result` to `yield`, so subagent sessions must now finish with the `yield` tool and the `requireYieldTool` option; `submit_result`/`requireSubmitResultTool` and old completion calls are no longer recognized
@@ -19,6 +23,9 @@
### Changed
- Updated `ast_edit` and `ast_grep` to pass file-selection intent through `path` (including inline globs and comma/space-separated path lists) instead of separate `glob` filters
- Changed `ast_grep` pagination API from `offset` to `skip`
- Changed `grep` truncation output to report `Result limit reached; narrow path.` and label match/result caps as `first N`
- Changed JSON tree output to truncate inline argument pairs by available width and add an ellipsis when values no longer fit in the display
- Changed JSON tree rendering to hide harness-internal `intent` and `__partialJson` fields from top-level tool output
- Simplified the `grep` tool schema by requiring `path`, folding glob and type filtering into path globs, auto-detecting multiline patterns, removing model-controlled context and limit options, and renaming result skipping to `skip`.
+3 -8
View File
@@ -177,16 +177,11 @@ export class CursorExecHandlers implements ICursorExecHandlers {
async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
const toolCallId = decodeToolCallId(args.toolCallId);
const grepPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
const toolResultMessage = await executeTool(this.options, "grep", toolCallId, {
pattern: args.pattern,
path: args.path || undefined,
glob: args.glob || undefined,
mode: args.outputMode || undefined,
context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
ignore_case: args.caseInsensitive || undefined,
type: args.type || undefined,
limit: args.headLimit ?? undefined,
multiline: args.multiline || undefined,
path: grepPath,
i: args.caseInsensitive || undefined,
});
return toolResultMessage;
}
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import * as nodePath from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-coding-agent";
import { StringEnum } from "@oh-my-pi/pi-ai";
import {
ChunkAnchorStyle,
ChunkEditOp,
@@ -2,12 +2,12 @@ Performs structural AST-aware rewrites via native ast-grep.
<instruction>
- Use for codemods and structural rewrites where plain text replace is unsafe
- `path` accepts a comma-separated list in addition to file/dir/glob
- Set `lang` explicitly in mixed-language trees for deterministic rewrites
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
- Language is inferred from `path`; narrow `path` to one language for deterministic rewrites
- Metavariables captured in `pat` (`$A`, `$$$ARGS`) are substituted into that entry's `out` template
- **Patterns match AST structure, not text.** `$NAME` = one node (captured); `$_` = one without binding; `$$$NAME` = zero-or-more (lazy — stops at next matchable element); `$$$` = zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid. Metavariable names are UPPERCASE and **MUST** be the whole AST node — partial text like `prefix$VAR` or `"hello $NAME"` does NOT work
- When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
- Rewrite patterns **MUST** parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`) and set `sel` to target the inner node — match and replacement target the selected node, not the wrapper. If ast-grep reports `Multiple AST nodes are detected`, wrap and use `sel`
- Rewrite patterns **MUST** parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`)
- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
- Delete matched code with empty `out`: `{"pat":"console.log($$$)","out":""}`
- Each rewrite is a 1:1 structural substitution — cannot split one capture across multiple nodes or merge multiple captures into one
@@ -19,22 +19,18 @@ Performs structural AST-aware rewrites via native ast-grep.
</output>
<examples>
# Rename a call site across a directory
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"lang":"typescript","path":"src/"}`
# Delete matching calls (empty `out` removes the node)
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"lang":"typescript","path":"src/"}`
# Rename a call site across TypeScript files
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"path":"src/**/*.ts"}`
# Delete matching calls
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"path":"src/**/*.ts"}`
# Rewrite import source path
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"lang":"typescript","path":"src/"}`
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"path":"src/**/*.ts"}`
# Modernize to optional chaining (same metavariable enforces identity)
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"lang":"typescript","path":"src/"}`
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"path":"src/**/*.ts"}`
# Swap two arguments using captures
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"lang":"typescript","path":"tests/"}`
# Rename a 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 method body fragment by wrapping in parseable context (sel targets the inner node)
`{"ops":[{"pat":"class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $_.parse($INPUT); $$$AFTER } }","out":"class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $SCHEMA.parse($INPUT); $$$AFTER } }"}],"sel":"method_definition","lang":"typescript","path":"src/tools/todo.ts"}`
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"path":"tests/**/*.ts"}`
# Python — convert print calls to logging
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"lang":"python","path":"src/"}`
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"path":"src/**/*.py"}`
</examples>
<critical>
@@ -2,18 +2,18 @@ Performs structural code search using AST matching via native ast-grep.
<instruction>
- Use when syntax shape matters more than raw text (calls, declarations, specific language constructs)
- `path` accepts a comma-separated list in addition to file/dir/glob
- Set `lang` explicitly in mixed-language trees to avoid parse noise from non-source files
- Multiple patterns in `pat` run in one native pass, merged, then `offset`/`limit` applied
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
- Language is inferred from `path`; narrow `path` to one language when mixed-language trees could cause parse noise
- `pat` is a single AST pattern. Run separate calls for distinct unrelated patterns
- **Patterns match AST structure, not text** — whitespace/formatting is ignored
- `$NAME` captures one node; `$_` matches one without binding; `$$$NAME` captures zero-or-more (lazy — stops at next matchable element); `$$$` matches zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid and produces a parse error
- Metavariable names are UPPERCASE and must be the whole AST node — partial-text like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` does NOT work; match the whole node instead
- When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
- Patterns **MUST** parse as a single valid AST node for the target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`) and set `sel` to target the inner node — results return for the selected node, not the outer wrapper. If ast-grep reports `Multiple AST nodes are detected`, the pattern isn't a single parseable node — wrap and use `sel`
- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG)`, or wrap a statement snippet and select `call_expression`. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
- Patterns **MUST** parse as a single valid AST node for the inferred target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`)
- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG);`, or wrap a statement snippet. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
- Declaration forms are structurally distinct — top-level `function foo`, class method `foo()`, and `const foo = () => {}` are different AST shapes; search the right form before concluding absence
- Loosest existence check: `pat: ["executeBash"]` with `sel: "identifier"`
- Loosest existence check: `pat: "executeBash"` with a narrow `path`
</instruction>
<output>
@@ -22,26 +22,20 @@ Performs structural code search using AST matching via native ast-grep.
</output>
<examples>
# Multi-pattern scoped search
`{"pat":["console.log($$$)","console.error($$$)"],"lang":"typescript","path":"src/"}`
# Named imports from a specific package (quoted string inside pattern)
`{"pat":["import { $$$IMPORTS } from \"react\""],"lang":"typescript","path":"src/"}`
# Arrow functions assigned to a const (distinct AST from function declarations)
`{"pat":["const $NAME = ($$$ARGS) => $BODY"],"lang":"typescript","path":"src/utils/"}`
# Search TypeScript files under src
`{"pat":"console.log($$$)","path":"src/**/*.ts"}`
# Named imports from a specific package
`{"pat":"import { $$$IMPORTS } from \"react\"","path":"src/**/*.ts"}`
# Arrow functions assigned to a const
`{"pat":"const $NAME = ($$$ARGS) => $BODY","path":"src/utils/**/*.ts"}`
# Method call on any object, ignoring method name with `$_`
`{"pat":["logger.$_($$$ARGS)"],"lang":"typescript","path":"src/"}`
# Contextual pattern with selector — match the identifier `foo`, not the whole call
`{"pat":["foo()"],"sel":"identifier","lang":"typescript","path":"src/utils.ts"}`
# Match a function declaration while tolerating any return type annotation (sel targets the inner node)
`{"pat":["async function processItems($$$ARGS): $_ { $$$BODY }"],"sel":"function_declaration","lang":"typescript","path":"src/worker.ts"}`
# Match a method body fragment by wrapping in parseable context and selecting the method
`{"pat":["class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $_.parse($INPUT); $$$AFTER } }"],"sel":"method_definition","lang":"typescript","path":"src/tools/todo.ts"}`
`{"pat":"logger.$_($$$ARGS)","path":"src/**/*.ts"}`
# Loosest existence check for a symbol in one file
`{"pat":["processItems"],"sel":"identifier","lang":"typescript","path":"src/worker.ts"}`
`{"pat":"processItems","path":"src/worker.ts"}`
</examples>
<critical>
- Avoid repo-root AST scans when the target is language-specific — narrow `path` first
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path`/`glob`/`lang` before concluding "no matches"
- Avoid repo-root scans — narrow `path` first
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path` before concluding "no matches"
- For broad/open-ended exploration across subsystems, use Task tool with explore subagent first
</critical>
@@ -2,9 +2,8 @@ Searches files using powerful regex matching.
<instruction>
- Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
- `path` also accepts comma-separated path lists; pair with `glob` when you need a relative file filter in addition to `type`
- For cross-line patterns like `struct \\{[\\s\\S]*?field`, set `multiline: true`
- If the pattern contains a literal `\n`, `multiline` defaults to true automatically
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
</instruction>
<output>
+12 -36
View File
@@ -15,7 +15,6 @@ import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import type { OutputMeta } from "./output-meta";
import {
combineSearchGlobs,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
@@ -46,21 +45,10 @@ const astEditSchema = Type.Object({
minItems: 1,
description: "rewrite ops",
}),
lang: Type.Optional(Type.String({ description: "language override", examples: ["typescript", "python"] })),
path: Type.Optional(
Type.String({
description: "path or glob to rewrite",
examples: ["src/", "src/foo.ts"],
}),
),
glob: Type.Optional(Type.String({ description: "glob filter", examples: ["**/*.ts"] })),
sel: Type.Optional(
Type.String({
description: "contextual pattern selector",
examples: ["function_declaration", "call_expression", "identifier"],
}),
),
limit: Type.Optional(Type.Number({ description: "max replacements" })),
path: Type.String({
description: "file, directory, glob, or comma-separated paths to rewrite",
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
}),
});
export interface AstEditToolDetails {
@@ -115,10 +103,6 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
seenPatterns.add(pat);
}
const normalizedRewrites = Object.fromEntries(ops);
const maxReplacements = params.limit !== undefined ? Math.floor(params.limit) : undefined;
if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
throw new ToolError("limit must be a positive number");
}
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
const formatScopePath = (targetPath: string): string => {
@@ -127,8 +111,11 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
};
let searchPath: string | undefined;
let scopePath: string | undefined;
let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
let globFilter: string | undefined;
const rawPath = normalizePathLikeInput(params.path);
if (rawPath.length === 0) {
throw new ToolError("`path` must be a non-empty path or glob");
}
if (rawPath) {
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
@@ -150,7 +137,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
}
}
@@ -167,12 +154,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
const result = await astEdit({
rewrites: normalizedRewrites,
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.sel?.trim(),
dryRun: true,
maxReplacements,
maxFiles,
failOnParseError: false,
signal,
@@ -295,7 +279,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
count: fileReplacementCounts.get(filePath) ?? 0,
}));
if (result.limitReached) {
outputLines.push("", "Limit reached; narrow path or increase limit.");
outputLines.push("", "Limit reached; narrow path.");
}
if (dedupedParseErrors.length) {
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
@@ -311,12 +295,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
apply: async (_reason: string) => {
const applyResult = await astEdit({
rewrites: normalizedRewrites,
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.sel?.trim(),
dryRun: false,
maxReplacements,
maxFiles,
failOnParseError: false,
});
@@ -391,10 +372,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
interface AstEditRenderArgs {
ops?: Array<{ pat?: string; out?: string }>;
lang?: string;
path?: string;
sel?: string;
limit?: number;
}
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -403,9 +381,7 @@ export const astEditToolRenderer = {
inline: true,
renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.lang) meta.push(`lang:${args.lang}`);
if (args.path) meta.push(`in ${args.path}`);
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
const rewriteCount = args.ops?.length ?? 0;
if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
@@ -495,7 +471,7 @@ export const astEditToolRenderer = {
const extraLines: string[] = [];
if (limitReached) {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
+47 -89
View File
@@ -15,7 +15,6 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatMatchLine } from "./match-line-format";
import type { OutputMeta } from "./output-meta";
import {
combineSearchGlobs,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
@@ -36,28 +35,12 @@ import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
const astGrepSchema = Type.Object({
pat: Type.Array(Type.String(), {
minItems: 1,
description: "ast patterns",
examples: [["console.log($$$)"]],
pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }),
path: Type.String({
description: "file, directory, glob, or comma-separated paths to search",
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
}),
lang: Type.Optional(Type.String({ description: "language override", examples: ["typescript", "python"] })),
path: Type.Optional(
Type.String({
description: "path or glob to search",
examples: ["src/", "src/foo.ts"],
}),
),
glob: Type.Optional(Type.String({ description: "glob filter", examples: ["**/*.ts"] })),
sel: Type.Optional(
Type.String({
description: "contextual pattern selector",
examples: ["function_declaration", "call_expression", "identifier"],
}),
),
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" })),
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
});
export interface AstGrepToolDetails {
@@ -94,55 +77,48 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
_context?: AgentToolContext,
): Promise<AgentToolResult<AstGrepToolDetails>> {
return untilAborted(signal, async () => {
const patterns = [...new Set(params.pat.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0))];
if (patterns.length === 0) {
throw new ToolError("`pat` must include at least one non-empty pattern");
const pattern = params.pat.trim();
if (pattern.length === 0) {
throw new ToolError("`pat` must be a non-empty pattern");
}
const limit = params.limit === undefined ? 50 : Math.floor(params.limit);
if (!Number.isFinite(limit) || limit < 1) {
throw new ToolError("Limit must be a positive number");
const patterns = [pattern];
const skip = params.skip === undefined ? 0 : Math.floor(params.skip);
if (!Number.isFinite(skip) || skip < 0) {
throw new ToolError("skip must be a non-negative number");
}
const offset = params.offset === undefined ? 0 : Math.floor(params.offset);
if (!Number.isFinite(offset) || offset < 0) {
throw new ToolError("Offset must be a non-negative number");
}
const context = params.context === undefined ? undefined : Math.floor(params.context);
if (context !== undefined && (!Number.isFinite(context) || context < 0)) {
throw new ToolError("Context must be a non-negative number");
}
const formatScopePath = (targetPath: string): string => {
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
return relative.length === 0 ? "." : relative;
};
let searchPath: string | undefined;
let scopePath: string | undefined;
let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
if (rawPath) {
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
scopePath = formatScopePath(searchPath);
let globFilter: string | undefined;
const rawPath = normalizePathLikeInput(params.path);
if (rawPath.length === 0) {
throw new ToolError("`path` must be a non-empty path or glob");
}
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.glob;
scopePath = multiSearchPath.scopePath;
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.glob;
scopePath = multiSearchPath.scopePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
scopePath = formatScopePath(searchPath);
}
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
}
}
@@ -158,13 +134,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
const result = await astGrep({
patterns,
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.sel?.trim(),
limit,
offset,
context,
offset: skip,
includeMeta: true,
signal,
});
@@ -201,7 +173,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (result.matches.length === 0) {
const noMatchMessage = dedupedParseErrors.length
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence."
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
: "No matches found";
const parseMessage = dedupedParseErrors.length
? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
@@ -308,13 +280,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
// =============================================================================
interface AstGrepRenderArgs {
pat?: string[];
lang?: string;
pat?: string;
path?: string;
sel?: string;
limit?: number;
offset?: number;
context?: number;
skip?: number;
}
const COLLAPSED_MATCH_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -323,15 +291,10 @@ export const astGrepToolRenderer = {
inline: true,
renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.lang) meta.push(`lang:${args.lang}`);
if (args.path) meta.push(`in ${args.path}`);
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.pat && args.pat.length > 1) meta.push(`${args.pat.length} patterns`);
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
const description = args.pat?.length === 1 ? args.pat[0] : args.pat ? `${args.pat.length} patterns` : "?";
const description = args.pat ?? "?";
const text = renderStatusLine({ icon: "pending", title: "AST Grep", description, meta }, uiTheme);
return new Text(text, 0, 0);
},
@@ -355,19 +318,14 @@ export const astGrepToolRenderer = {
const limitReached = details?.limitReached ?? false;
if (matchCount === 0) {
const description = args?.pat?.length === 1 ? args.pat[0] : undefined;
const description = args?.pat;
const meta = ["0 matches"];
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
if (details?.parseErrors?.length) {
lines.push(
uiTheme.fg(
"warning",
"Query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence",
),
);
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
@@ -384,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?.pat?.length === 1 ? args.pat[0] : undefined;
const description = args?.pat;
const header = renderStatusLine(
{ icon: limitReached ? "warning" : "success", title: "AST Grep", description, meta },
uiTheme,
+58 -95
View File
@@ -19,7 +19,6 @@ import { createFileRecorder } from "./file-recorder";
import { formatMatchLine } from "./match-line-format";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import {
combineSearchGlobs,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
@@ -38,16 +37,13 @@ import { toolResult } from "./tool-result";
const grepSchema = Type.Object({
pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
path: Type.Optional(Type.String({ description: "path to search", examples: ["src/", "src/foo.ts"] })),
glob: Type.Optional(Type.String({ description: "glob filter", examples: ["*.js"] })),
type: Type.Optional(Type.String({ description: "file type filter", examples: ["js", "py", "rust"] })),
path: Type.String({
description: "file, directory, glob, comma-separated paths, or internal URL to search",
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
}),
i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
pre: Type.Optional(Type.Number({ description: "lines before matches" })),
post: Type.Optional(Type.Number({ description: "lines after matches" })),
multiline: Type.Optional(Type.Boolean({ description: "multiline matching" })),
gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
limit: Type.Optional(Type.Number({ description: "max matches", default: 20 })),
offset: Type.Optional(Type.Number({ description: "skip first n matches", default: 0 })),
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
});
export type GrepToolInput = Static<typeof grepSchema>;
@@ -98,7 +94,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
_toolContext?: AgentToolContext,
): Promise<AgentToolResult<GrepToolDetails>> {
const { pattern, path: searchDir, glob, type, i, gitignore, pre, post, multiline, limit, offset } = params;
const { pattern, path: searchDir, i, gitignore, skip } = params;
return untilAborted(signal, async () => {
const normalizedPattern = pattern.trim();
@@ -107,25 +103,16 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
throw new ToolError("Pattern must not be empty");
}
const normalizedOffset = offset === undefined ? 0 : Number.isFinite(offset) ? Math.floor(offset) : Number.NaN;
if (normalizedOffset < 0 || !Number.isFinite(normalizedOffset)) {
throw new ToolError("Offset must be a non-negative number");
const normalizedSkip = skip === undefined ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
throw new ToolError("Skip must be a non-negative number");
}
const rawLimit = limit === undefined ? undefined : Number.isFinite(limit) ? Math.floor(limit) : Number.NaN;
if (rawLimit !== undefined && (!Number.isFinite(rawLimit) || rawLimit < 0)) {
throw new ToolError("Limit must be a non-negative number");
}
const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;
const defaultContextBefore = this.session.settings.get("grep.contextBefore");
const defaultContextAfter = this.session.settings.get("grep.contextAfter");
const normalizedContextBefore = pre ?? defaultContextBefore;
const normalizedContextAfter = post ?? defaultContextAfter;
const normalizedContextBefore = this.session.settings.get("grep.contextBefore");
const normalizedContextAfter = this.session.settings.get("grep.contextAfter");
const ignoreCase = i ?? false;
const useGitignore = gitignore ?? true;
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
const effectiveMultiline = multiline ?? patternHasNewline;
const effectiveMultiline = patternHasNewline;
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
const formatScopePath = (targetPath: string): string => {
@@ -135,41 +122,36 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
let searchPath: string;
let scopePath: string;
let exactFilePaths: string[] | undefined;
let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined;
const internalRouter = this.session.internalRouter;
if (searchDir?.trim()) {
const rawPath = normalizePathLikeInput(searchDir);
if (internalRouter?.canHandle(rawPath)) {
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot grep internal URL without a backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
exactFilePaths = multiSearchPath.exactFilePaths;
scopePath = multiSearchPath.scopePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
if (parsedPath.glob) {
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
}
scopePath = formatScopePath(searchPath);
}
}
} else {
searchPath = resolveToCwd(".", this.session.cwd);
scopePath = ".";
let globFilter: string | undefined;
const rawPath = normalizePathLikeInput(searchDir);
if (rawPath.length === 0) {
throw new ToolError("`path` must be a non-empty path or glob");
}
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot grep internal URL without a backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
exactFilePaths = multiSearchPath.exactFilePaths;
scopePath = multiSearchPath.scopePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
}
}
let isDirectory: boolean;
try {
const stat = await Bun.file(searchPath).stat();
@@ -180,7 +162,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
}
const effectiveOutputMode = GrepOutputMode.Content;
const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT;
const effectiveLimit = DEFAULT_MATCH_LIMIT;
const internalLimit = Math.min(effectiveLimit * 5, 2000);
// Run grep
@@ -194,7 +176,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
{
pattern: normalizedPattern,
path: exactFilePath,
type: type?.trim() || undefined,
ignoreCase,
multiline: effectiveMultiline,
hidden: true,
@@ -211,7 +192,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
}
const offsetMatches = matches.slice(normalizedOffset);
const offsetMatches = matches.slice(normalizedSkip);
result = {
matches: offsetMatches,
totalMatches: offsetMatches.length,
@@ -225,14 +206,13 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
pattern: normalizedPattern,
path: searchPath,
glob: globFilter,
type: type?.trim() || undefined,
ignoreCase,
multiline: effectiveMultiline,
hidden: true,
gitignore: useGitignore,
cache: false,
maxCount: internalLimit,
offset: normalizedOffset > 0 ? normalizedOffset : undefined,
offset: normalizedSkip > 0 ? normalizedSkip : undefined,
contextBefore: normalizedContextBefore,
contextAfter: normalizedContextAfter,
maxColumns: DEFAULT_MAX_COLUMN,
@@ -407,6 +387,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
outputLines.push(...renderChunkedMatchesForFile(relativePath));
}
}
if (matchLimitReached || result.limitReached) {
outputLines.push("", "Result limit reached; narrow path.");
}
const rawOutput = outputLines.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated);
@@ -424,12 +407,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
resultLimitReached: result.limitReached ? internalLimit : undefined,
};
if (truncation.truncated) details.truncation = truncation;
const resultBuilder = toolResult(details)
.text(truncation.content)
.limits({
matchLimit: matchLimitReached ? effectiveLimit : undefined,
resultLimit: result.limitReached ? internalLimit : undefined,
});
const resultBuilder = toolResult(details).text(truncation.content);
if (truncation.truncated) {
resultBuilder.truncation(truncation, { direction: "head" });
}
@@ -524,6 +502,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
if (hasContextLines && outputLines.length > 0) {
outputLines.unshift("[grep] match lines use ':'; context lines use '-'.");
}
if (matchLimitReached || result.limitReached) {
outputLines.push("", "Result limit reached; narrow path.");
}
const rawOutput = outputLines.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const output = truncation.content;
@@ -546,11 +527,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
if (linesTruncated) details.linesTruncated = true;
const resultBuilder = toolResult(details)
.text(output)
.limits({
matchLimit: matchLimitReached ? effectiveLimit : undefined,
resultLimit: result.limitReached ? internalLimit : undefined,
columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined,
});
.limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined });
if (truncation.truncated) {
resultBuilder.truncation(truncation, { direction: "head" });
}
@@ -566,15 +543,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
interface GrepRenderArgs {
pattern: string;
path?: string;
glob?: string;
type?: string;
i?: boolean;
gitignore?: boolean;
pre?: number;
post?: number;
multiline?: boolean;
limit?: number;
offset?: number;
skip?: number;
}
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -584,19 +555,9 @@ export const grepToolRenderer = {
renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.path) meta.push(`in ${args.path}`);
if (args.glob) meta.push(`glob:${args.glob}`);
if (args.type) meta.push(`type:${args.type}`);
if (args.i) meta.push("case:insensitive");
if (args.gitignore === false) meta.push("gitignore:false");
if (args.pre !== undefined && args.pre > 0) {
meta.push(`pre:${args.pre}`);
}
if (args.post !== undefined && args.post > 0) {
meta.push(`post:${args.post}`);
}
if (args.multiline) meta.push("multiline");
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.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
const text = renderStatusLine(
{ icon: "pending", title: "Grep", description: args.pattern || "?", meta },
@@ -708,9 +669,11 @@ export const grepToolRenderer = {
}
}
const renderedMatchLimit = details?.matchLimitReached ?? limits?.matchLimit?.reached;
const renderedResultLimit = details?.resultLimitReached ?? limits?.resultLimit?.reached;
const truncationReasons: string[] = [];
if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`);
if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
if (renderedMatchLimit) truncationReasons.push(`first ${renderedMatchLimit} matches`);
if (renderedResultLimit) truncationReasons.push(`first ${renderedResultLimit} results`);
if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId));
+31 -21
View File
@@ -1195,7 +1195,7 @@ function b() {
expect(output).not.toContain("schema-other.test.ts");
expect(result.details?.fileCount).toBe(2);
});
it("should combine globbing from path and glob parameters", async () => {
it("should accept nested wildcard filters in the path parameter", async () => {
const packageDir = path.join(testDir, "node_modules", ".bun");
const aiDir = path.join(packageDir, "ai@6.0.119+build123", "node_modules", "ai");
const nestedDir = path.join(aiDir, "nested");
@@ -1207,8 +1207,7 @@ function b() {
const result = await grepTool.execute("test-call-11-path-and-glob", {
pattern: "providerOptions",
path: `${packageDir}/ai@6.0.119+*/node_modules/ai`,
glob: "**/*.{d.ts,ts}",
path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`,
gitignore: false,
});
@@ -1220,17 +1219,16 @@ function b() {
expect(result.details?.fileCount).toBe(2);
});
it("should respect global limit and include context lines", async () => {
it("should include configured context lines", async () => {
const testFile = path.join(testDir, "context.txt");
const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n");
fs.writeFileSync(testFile, content);
const result = await grepTool.execute("test-call-12", {
const contextSettings = Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 });
const contextGrepTool = wrapToolWithMetaNotice(new GrepTool(createTestToolSession(testDir, contextSettings)));
const result = await contextGrepTool.execute("test-call-12", {
pattern: "match",
path: testFile,
limit: 1,
pre: 1,
post: 1,
});
const output = getTextOutput(result);
@@ -1238,12 +1236,26 @@ function b() {
expect(output).toMatch(/\b1-before/);
expect(output).toMatch(/\b2:match one/);
expect(output).toMatch(/\b3-after/);
expect(output).toContain("[1 matches limit reached. Use limit=2 for more]");
// Ensure second match is not present
expect(output).not.toContain("match two");
expect(output).toMatch(/\b5:match two/);
});
it("should group multi-file matches and distribute limit with round-robin", async () => {
it("should skip matches with the skip parameter", async () => {
const testFile = path.join(testDir, "skip.txt");
fs.writeFileSync(testFile, ["needle one", "needle two", "needle three"].join("\n"));
const result = await grepTool.execute("test-call-12-skip", {
pattern: "needle",
path: testFile,
skip: 1,
});
const output = getTextOutput(result);
expect(output).not.toContain("needle one");
expect(output).toContain("needle two");
expect(output).toContain("needle three");
});
it("should group multi-file matches", async () => {
for (let i = 1; i <= 3; i++) {
fs.writeFileSync(path.join(testDir, `file-${i}.txt`), `needle in file ${i}\nextra needle ${i}`);
}
@@ -1252,7 +1264,6 @@ function b() {
const result = await grepTool.execute("test-call-13-round-robin", {
pattern: "needle",
path: testDir,
limit: 4,
});
const output = getTextOutput(result);
@@ -1261,19 +1272,18 @@ function b() {
expect(output).toContain("# file-3.txt");
expect(output).toContain("# dominant.txt");
expect(output).not.toContain("# .");
expect(output).toContain("[4 matches limit reached. Use limit=8 for more]");
expect(output).not.toContain("Result limit reached");
expect(result.details?.fileCount).toBe(4);
expect(result.details?.matchCount).toBe(4);
expect(result.details?.matchCount).toBe(10);
});
it("should not repeat file headings when round-robin selects multiple matches per file", async () => {
it("should not repeat file headings for multiple matches per file", async () => {
fs.writeFileSync(path.join(testDir, "alpha.txt"), "needle a1\nneedle a2\nneedle a3");
fs.writeFileSync(path.join(testDir, "beta.txt"), "needle b1\nneedle b2\nneedle b3");
const result = await grepTool.execute("test-call-14-grouped-headings", {
pattern: "needle",
path: testDir,
limit: 4,
});
const output = getTextOutput(result);
@@ -1283,8 +1293,8 @@ function b() {
expect(betaHeadings).toBe(1);
expect(result.details?.fileMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "alpha.txt", count: 2 }),
expect.objectContaining({ path: "beta.txt", count: 2 }),
expect.objectContaining({ path: "alpha.txt", count: 3 }),
expect.objectContaining({ path: "beta.txt", count: 3 }),
]),
);
});
@@ -1368,7 +1378,7 @@ function b() {
expect(result.details?.fileCount).toBe(1);
expect(result.details?.matchCount).toBe(1);
});
it("should apply default limit of 20 when limit is not provided", async () => {
it("should apply the fixed default match cap", async () => {
const lines = Array.from({ length: 60 }, (_, i) => `needle ${i + 1}`);
fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n"));
@@ -1378,7 +1388,7 @@ function b() {
});
const output = getTextOutput(result);
expect(output).toContain("[20 matches limit reached. Use limit=40 for more]");
expect(output).toContain("Result limit reached; narrow path.");
expect(result.details?.matchCount).toBe(20);
expect(result.details?.matchLimitReached).toBe(20);
});
@@ -72,7 +72,6 @@ describe("ast_edit tool schema", () => {
const result = await tool!.execute("ast-edit-test", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: filePath,
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
@@ -109,7 +108,6 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-preview", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: filePath,
});
expect(previewResult.details).toBeDefined();
@@ -155,7 +153,6 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-preview", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: filePath,
});
expect((previewResult.details as { totalReplacements?: number } | undefined)?.totalReplacements).toBe(1);
@@ -206,9 +203,7 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-glob", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: `${packagesDir}/pkg-*/src`,
glob: "**/*.ts",
path: `${packagesDir}/pkg-*/src/**/*.ts`,
});
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
@@ -17,7 +17,7 @@ function createTestSession(cwd = "/tmp/test", overrides: Partial<ToolSession> =
}
describe("ast_grep parse errors", () => {
it("collapses per-pattern parse errors for the same file", async () => {
it("reports parse errors for the searched file", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-grep-parse-"));
try {
const filePath = path.join(tempDir, "broken.ts");
@@ -28,8 +28,7 @@ describe("ast_grep parse errors", () => {
expect(tool).toBeDefined();
const result = await tool!.execute("ast-grep-parse", {
pat: ["someUnlikelyCall($A)", "anotherUnlikelyCall($A)"],
lang: "typescript",
pat: "someUnlikelyCall($A)",
path: filePath,
});
@@ -42,7 +41,6 @@ describe("ast_grep parse errors", () => {
expect(details?.parseErrors).toHaveLength(1);
expect(details?.parseErrors?.[0]).toContain("broken.ts: parse error (syntax tree contains error nodes)");
expect(details?.parseErrors?.[0]).not.toContain("someUnlikelyCall($A):");
expect(details?.parseErrors?.[0]).not.toContain("anotherUnlikelyCall($A):");
expect(text.match(/parse error \(syntax tree contains error nodes\)/g)?.length ?? 0).toBe(1);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
@@ -65,11 +63,8 @@ describe("ast_grep parse errors", () => {
expect(tool).toBeDefined();
const result = await tool!.execute("ast-grep-glob", {
pat: ["providerOptions"],
sel: "identifier",
lang: "typescript",
path: `${packagesDir}/pkg-*/src`,
glob: "**/*.ts",
pat: "providerOptions",
path: `${packagesDir}/pkg-*/src/**/*.ts`,
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
@@ -100,9 +95,7 @@ describe("ast_grep parse errors", () => {
expect(tool).toBeDefined();
const result = await tool!.execute("ast-grep-tlaplus", {
pat: ["Inc"],
sel: "identifier",
lang: "pluscal",
pat: "Inc",
path: filePath,
});
@@ -34,7 +34,7 @@ describe("grepToolRenderer", () => {
const bodyLines = renderedLines.slice(1);
expect(bodyLines).toHaveLength(6);
expect(bodyLines.at(-1)).toContain("truncated: limit 6 matches");
expect(bodyLines.at(-1)).toContain("truncated: first 6 matches");
expect(bodyLines.some(line => line.includes("1 more match"))).toBe(true);
expect(bodyLines.some(line => line.includes("gamma:1"))).toBe(false);
});
@@ -116,11 +116,8 @@ describe("tool path root alias", () => {
if (!tool) throw new Error("Missing ast_grep tool");
const result = await tool.execute("ast-grep-root-alias", {
pat: ["rootAliasSymbol"],
sel: "identifier",
lang: "typescript",
path: "/",
glob: "**/*.ts",
pat: "rootAliasSymbol",
path: "/**/*.ts",
});
const details = result.details as { scopePath?: string } | undefined;
@@ -143,9 +140,7 @@ describe("tool path root alias", () => {
const preview = await tool.execute("ast-edit-root-alias", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: "/",
glob: "**/*.ts",
path: "/**/*.ts",
});
const details = preview.details as { scopePath?: string; totalReplacements?: number } | undefined;
@@ -134,11 +134,8 @@ describe("search tool path lists", () => {
if (!tool) throw new Error("Missing ast_grep tool");
const result = await tool.execute("ast-grep-quoted-path", {
pat: ["providerOptions"],
sel: "identifier",
lang: "typescript",
path: '"packages/"',
glob: '"**/*.ts"',
pat: "providerOptions",
path: '"packages/**/*.ts"',
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -156,11 +153,8 @@ describe("search tool path lists", () => {
if (!tool) throw new Error("Missing ast_grep tool");
const result = await tool.execute("ast-grep-comma-paths", {
pat: ["providerOptions"],
sel: "identifier",
lang: "typescript",
path: "apps/,packages/,phases/",
glob: "**/*.ts",
pat: "providerOptions",
path: "apps/**/*.ts,packages/**/*.ts,phases/**/*.ts",
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -171,7 +165,7 @@ describe("search tool path lists", () => {
expect(text).toContain("## └─ ast.ts");
expect(text).not.toContain("# other");
expect(details?.fileCount).toBe(3);
expect(details?.scopePath).toBe("apps/, packages/, phases/");
expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts");
});
it("ast_edit applies across a space-separated path list", async () => {
@@ -189,9 +183,7 @@ describe("search tool path lists", () => {
const preview = await tool.execute("ast-edit-space-paths", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: "apps/ packages/ phases/",
glob: "**/*.ts",
path: "apps/**/*.ts packages/**/*.ts phases/**/*.ts",
});
const text = getText(preview);
const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined;
@@ -202,7 +194,7 @@ describe("search tool path lists", () => {
expect(text).toContain("## └─ ast.ts (1 replacement)");
expect(text).not.toContain("# other");
expect(details?.totalReplacements).toBe(3);
expect(details?.scopePath).toBe("apps/, packages/, phases/");
expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts");
queue.nextToolChoice();
const invoker = queue.peekInFlightInvoker();
@@ -319,7 +311,6 @@ describe("search tool path lists", () => {
const result = await tool.execute("grep-no-empty-headings", {
pattern: "shared-needle",
path: "apps/,packages/,phases/",
limit: 2,
});
const lines = getText(result).split("\n");
@@ -338,7 +329,11 @@ describe("search tool path lists", () => {
it("grep explains context-line gutters without changing match and context separators", async () => {
await Bun.write(path.join(tempDir, "context.txt"), "#if FLAG\nneedle\n#endif\n");
const tools = await createTools(createTestSession(tempDir));
const tools = await createTools(
createTestSession(tempDir, {
settings: Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 }),
}),
);
const tool = tools.find(entry => entry.name === "grep");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing grep tool");
@@ -346,8 +341,6 @@ describe("search tool path lists", () => {
const result = await tool.execute("grep-context-label", {
pattern: "needle",
path: "context.txt",
pre: 1,
post: 1,
});
const text = getText(result);