diff --git a/src/workflow-test-helpers.test.ts b/src/workflow-test-helpers.test.ts index 905e29e5..b871bbca 100644 --- a/src/workflow-test-helpers.test.ts +++ b/src/workflow-test-helpers.test.ts @@ -26,6 +26,18 @@ test('stepRunsCommand rejects commands that are only mentioned, not run', () => assert.equal(runs('bun run verify'), false); }); +test('stepRunsCommand ignores separators inside quotes and inline comments', () => { + assert.equal(runs('echo \'note; bun run verify --flag "$VALUE"\''), false); + assert.equal(runs('echo "note && bun run verify --flag \\"$VALUE\\""'), false); + assert.equal(runs("printf '%s\\n' 'a | bun run verify --flag \"$VALUE\"'"), false); + assert.equal(runs('if false; then # bun run verify --flag "$VALUE"'), false); + // A trailing comment does not hide the command in front of it. + assert.equal(runs('bun run verify --flag "$VALUE" # keep this'), true); + // A pipe is a real separator; a redirect is not. + assert.equal(runs('cat notes | bun run verify --flag "$VALUE"'), true); + assert.equal(stepRunsCommand({ run: 'gh release view "$V" 2>&1 | tee log' }, /^tee\b/), true); +}); + test('commandPositions splits on separators and strips control-flow prefixes', () => { assert.deepEqual( commandPositions({ run: 'if gh release view "$V"; then\ngh release edit "$V"\nfi' }), diff --git a/src/workflow-test-helpers.ts b/src/workflow-test-helpers.ts index 95a2fa59..babd996b 100644 --- a/src/workflow-test-helpers.ts +++ b/src/workflow-test-helpers.ts @@ -53,14 +53,72 @@ export function executableRunLines(step: WorkflowStep): string[] { // Leading shell keywords and operators that can precede a real command. const COMMAND_PREFIX = /^(?:if|elif|while|until|then|else|do|!|&&|\|\||\(|\{)\s+/; +// Splits one shell line on command separators, tracking quotes so a separator +// inside a string is not treated as a command break, and stopping at an +// unquoted inline comment. +function splitCommandSeparators(line: string): string[] { + const segments: string[] = []; + let current = ''; + let quote: "'" | '"' | null = null; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]!; + + if (quote) { + current += char; + if (char === '\\' && quote === '"' && index + 1 < line.length) { + current += line[index + 1]!; + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"') { + quote = char; + current += char; + continue; + } + + // An unquoted # starts a comment when it opens a word; the rest is inert. + if (char === '#' && (current === '' || /\s$/.test(current))) { + break; + } + + const next = line[index + 1]; + if (char === ';') { + segments.push(current); + current = ''; + continue; + } + if ((char === '&' || char === '|') && next === char) { + segments.push(current); + current = ''; + index += 1; + continue; + } + // A lone pipe separates commands; a redirect such as 2>&1 does not. + if (char === '|' && !/[0-9<>&]$/.test(current)) { + segments.push(current); + current = ''; + continue; + } + + current += char; + } + + segments.push(current); + return segments; +} + // Command positions within a step's shell body: each line split on separators, // with control-flow prefixes stripped. A pattern anchored with ^ therefore // matches only where a command actually starts, so text quoted inside an // `echo`/`printf` argument is not mistaken for the command running. export function commandPositions(step: WorkflowStep): string[] { return executableRunLines(step).flatMap((line) => - line - .split(/;|&&|\|\||(?])\|(?!\|)/) + splitCommandSeparators(line) .map((segment) => { let candidate = segment.trim(); let stripped = candidate.replace(COMMAND_PREFIX, '');