Skip to content

Commit 65a72e7

Browse files
authored
fix(core): merge grep context hunks (#17274)
1 parent 259d409 commit 65a72e7

3 files changed

Lines changed: 148 additions & 25 deletions

File tree

.changeset/salty-rules-scream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed grep context output so overlapping matches are shown once.

packages/core/src/workspace/tools/__tests__/grep.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,91 @@ describe('workspace_grep', () => {
129129
expect(result).toContain('ctx.ts:5- line5');
130130
});
131131

132+
it('should merge overlapping context windows without duplicating lines', async () => {
133+
await fs.writeFile(path.join(tempDir, 'f.ts'), 'MATCH_A\nx\nMATCH_B\ny\nz');
134+
const workspace = new Workspace({
135+
filesystem: new LocalFilesystem({ basePath: tempDir }),
136+
});
137+
const tools = await createWorkspaceTools(workspace);
138+
139+
const result = await tools[WORKSPACE_TOOLS.FILESYSTEM.GREP].execute(
140+
{
141+
pattern: 'MATCH',
142+
path: 'f.ts',
143+
contextLines: 2,
144+
},
145+
{ workspace },
146+
);
147+
148+
expect(result).toBe(
149+
[
150+
'2 matches across 1 file',
151+
'---',
152+
'f.ts:1:1: MATCH_A',
153+
'f.ts:2- x',
154+
'f.ts:3:1: MATCH_B',
155+
'f.ts:4- y',
156+
'f.ts:5- z',
157+
].join('\n'),
158+
);
159+
});
160+
161+
it('should normalize fractional context lines before rendering context', async () => {
162+
await fs.writeFile(path.join(tempDir, 'fractional.ts'), 'before\nTARGET\nafter\nextra');
163+
const workspace = new Workspace({
164+
filesystem: new LocalFilesystem({ basePath: tempDir }),
165+
});
166+
const tools = await createWorkspaceTools(workspace);
167+
168+
const result = await tools[WORKSPACE_TOOLS.FILESYSTEM.GREP].execute(
169+
{
170+
pattern: 'TARGET',
171+
path: 'fractional.ts',
172+
contextLines: 1.5,
173+
},
174+
{ workspace },
175+
);
176+
177+
expect(result).toBe(
178+
[
179+
'1 match across 1 file',
180+
'---',
181+
'fractional.ts:1- before',
182+
'fractional.ts:2:1: TARGET',
183+
'fractional.ts:3- after',
184+
].join('\n'),
185+
);
186+
});
187+
188+
it('should separate distinct context hunks without a trailing separator', async () => {
189+
await fs.writeFile(path.join(tempDir, 'split.ts'), 'TARGET_A\nctxA\ngap1\ngap2\nctxB\nTARGET_B');
190+
const workspace = new Workspace({
191+
filesystem: new LocalFilesystem({ basePath: tempDir }),
192+
});
193+
const tools = await createWorkspaceTools(workspace);
194+
195+
const result = await tools[WORKSPACE_TOOLS.FILESYSTEM.GREP].execute(
196+
{
197+
pattern: 'TARGET',
198+
path: 'split.ts',
199+
contextLines: 1,
200+
},
201+
{ workspace },
202+
);
203+
204+
expect(result).toBe(
205+
[
206+
'2 matches across 1 file',
207+
'---',
208+
'split.ts:1:1: TARGET_A',
209+
'split.ts:2- ctxA',
210+
'--',
211+
'split.ts:5- ctxB',
212+
'split.ts:6:1: TARGET_B',
213+
].join('\n'),
214+
);
215+
});
216+
132217
it('should limit matches per file with maxCount', async () => {
133218
const lines = Array.from({ length: 200 }, (_, i) => `match_${i}`).join('\n');
134219
await fs.writeFile(path.join(tempDir, 'big.ts'), lines);

packages/core/src/workspace/tools/grep.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ Usage:
166166
let truncated = false;
167167
const MAX_LINE_LENGTH = 500;
168168
const GLOBAL_CAP = 1000;
169+
const normalizedContextLines = Math.max(0, Math.floor(contextLines));
170+
let emittedContextHunk = false;
169171

170172
for (const filePath of filePaths) {
171173
if (truncated) break;
@@ -181,6 +183,7 @@ Usage:
181183

182184
const lines = content.split('\n');
183185
let fileMatchCount = 0;
186+
const fileMatches: Array<{ lineIndex: number; columnIndex: number }> = [];
184187

185188
for (let i = 0; i < lines.length; i++) {
186189
const currentLine = lines[i]!;
@@ -191,31 +194,7 @@ Usage:
191194

192195
filesWithMatches.add(filePath);
193196

194-
let lineContent = currentLine;
195-
if (lineContent.length > MAX_LINE_LENGTH) {
196-
lineContent = lineContent.slice(0, MAX_LINE_LENGTH) + '...';
197-
}
198-
199-
// Add context lines before the match
200-
if (contextLines > 0) {
201-
const beforeStart = Math.max(0, i - contextLines);
202-
for (let b = beforeStart; b < i; b++) {
203-
outputLines.push(`${filePath}:${b + 1}- ${lines[b]}`);
204-
}
205-
}
206-
207-
// Add the matching line
208-
outputLines.push(`${filePath}:${i + 1}:${lineMatch.index + 1}: ${lineContent}`);
209-
210-
// Add context lines after the match
211-
if (contextLines > 0) {
212-
const afterEnd = Math.min(lines.length - 1, i + contextLines);
213-
for (let a = i + 1; a <= afterEnd; a++) {
214-
outputLines.push(`${filePath}:${a + 1}- ${lines[a]}`);
215-
}
216-
// Separator between context groups
217-
outputLines.push('--');
218-
}
197+
fileMatches.push({ lineIndex: i, columnIndex: lineMatch.index });
219198

220199
totalMatchCount++;
221200
fileMatchCount++;
@@ -229,6 +208,60 @@ Usage:
229208
break;
230209
}
231210
}
211+
212+
if (normalizedContextLines > 0) {
213+
const hunks: Array<{
214+
start: number;
215+
end: number;
216+
matchesByLine: Map<number, number>;
217+
}> = [];
218+
219+
for (const match of fileMatches) {
220+
const start = Math.max(0, match.lineIndex - normalizedContextLines);
221+
const end = Math.min(lines.length - 1, match.lineIndex + normalizedContextLines);
222+
const previousHunk = hunks[hunks.length - 1];
223+
224+
if (previousHunk && start <= previousHunk.end + 1) {
225+
previousHunk.end = Math.max(previousHunk.end, end);
226+
previousHunk.matchesByLine.set(match.lineIndex, match.columnIndex);
227+
} else {
228+
hunks.push({
229+
start,
230+
end,
231+
matchesByLine: new Map([[match.lineIndex, match.columnIndex]]),
232+
});
233+
}
234+
}
235+
236+
for (const hunk of hunks) {
237+
if (emittedContextHunk) {
238+
outputLines.push('--');
239+
}
240+
emittedContextHunk = true;
241+
242+
for (let i = hunk.start; i <= hunk.end; i++) {
243+
const columnIndex = hunk.matchesByLine.get(i);
244+
245+
if (columnIndex !== undefined) {
246+
let lineContent = lines[i]!;
247+
if (lineContent.length > MAX_LINE_LENGTH) {
248+
lineContent = lineContent.slice(0, MAX_LINE_LENGTH) + '...';
249+
}
250+
outputLines.push(`${filePath}:${i + 1}:${columnIndex + 1}: ${lineContent}`);
251+
} else {
252+
outputLines.push(`${filePath}:${i + 1}- ${lines[i]}`);
253+
}
254+
}
255+
}
256+
} else {
257+
for (const match of fileMatches) {
258+
let lineContent = lines[match.lineIndex]!;
259+
if (lineContent.length > MAX_LINE_LENGTH) {
260+
lineContent = lineContent.slice(0, MAX_LINE_LENGTH) + '...';
261+
}
262+
outputLines.push(`${filePath}:${match.lineIndex + 1}:${match.columnIndex + 1}: ${lineContent}`);
263+
}
264+
}
232265
}
233266

234267
// Summary line — placed at the top so it's always visible after truncation

0 commit comments

Comments
 (0)