Skip to content

Commit 6738be1

Browse files
grypezclaude
andcommitted
fix(caprock): include redirected_statement stages in pipeline clause decomposition
Pipeline stages wrapped in a redirected_statement (e.g. `cmd 2>&1 | tail -30`) were silently dropped, causing only the tail end of the pipeline to appear as an invocation. A provision on `tail *` would then incorrectly auto-accept the entire command. Now all stages are collected regardless of redirect wrappers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 043059e commit 6738be1

2 files changed

Lines changed: 58 additions & 3 deletions

File tree

packages/caprock/src/bash.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,5 +281,24 @@ describe('decompose', () => {
281281
expect(result.clauses).toHaveLength(1);
282282
expect(result.clauses[0]).toHaveLength(2);
283283
});
284+
285+
it('includes a redirected_statement pipeline stage in the clause', () => {
286+
// `cmd 2>&1 | tail -30` — the first stage is a redirected_statement wrapping a command
287+
const result = decompose('yarn build 2>&1 | tail -30');
288+
expect(result.ok).toBe(true);
289+
expect(result.clauses).toHaveLength(1);
290+
expect(result.clauses[0]).toHaveLength(2);
291+
expect(result.clauses[0]?.[0]?.name).toBe('yarn');
292+
expect(result.clauses[0]?.[1]?.name).toBe('tail');
293+
});
294+
295+
it('captures stderr redirect on a pipeline stage', () => {
296+
const result = decompose('yarn build 2>&1 | tail -30');
297+
expect(result.ok).toBe(true);
298+
const firstStage = result.clauses[0]?.[0];
299+
expect(firstStage?.redirects).toStrictEqual([
300+
{ kind: 'fd-dup', target: '1' },
301+
]);
302+
});
284303
});
285304
});

packages/caprock/src/bash.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,17 @@ function collectClauses(node: Parser.SyntaxNode): Pipeline[] {
166166
return result;
167167
}
168168
case 'pipeline': {
169-
// All commands in this pipeline form one clause
169+
// All commands in this pipeline form one clause.
170+
// Each stage may be a bare `command` or a `redirected_statement` wrapping one.
170171
const cmds: ParsedCommand[] = [];
171172
for (let i = 0; i < node.namedChildCount; i++) {
172173
const child = node.namedChild(i);
173-
if (child !== null && child.type === 'command') {
174-
cmds.push(extractCommand(child));
174+
if (child === null) {
175+
continue;
176+
}
177+
const cmd = extractPipelineStage(child);
178+
if (cmd !== null) {
179+
cmds.push(cmd);
175180
}
176181
}
177182
return cmds.length > 0 ? [cmds] : [];
@@ -200,6 +205,37 @@ function collectClauses(node: Parser.SyntaxNode): Pipeline[] {
200205
}
201206
}
202207

208+
/**
209+
* Extract a ParsedCommand from a single pipeline stage node.
210+
*
211+
* A pipeline stage is either a bare `command` or a `redirected_statement`
212+
* wrapping a command with I/O redirects (e.g. `cmd 2>&1`).
213+
*
214+
* @param node - A named child of a `pipeline` node.
215+
* @returns The extracted ParsedCommand, or null if the stage is not a command.
216+
*/
217+
function extractPipelineStage(node: Parser.SyntaxNode): ParsedCommand | null {
218+
if (node.type === 'command') {
219+
return extractCommand(node);
220+
}
221+
if (node.type === 'redirected_statement') {
222+
for (let i = 0; i < node.namedChildCount; i++) {
223+
const child = node.namedChild(i);
224+
if (
225+
child !== null &&
226+
child.type !== 'file_redirect' &&
227+
child.type !== 'heredoc_redirect' &&
228+
child.type !== 'herestring_redirect'
229+
) {
230+
if (child.type === 'command') {
231+
return extractCommand(child);
232+
}
233+
}
234+
}
235+
}
236+
return null;
237+
}
238+
203239
/**
204240
* Determine where in a pipeline a command sits.
205241
*

0 commit comments

Comments
 (0)