From 107c6d1b06e8b6ae4456a96c8a832851b2e621ff Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 23 Aug 2026 04:20:26 -0700 Subject: [PATCH] test(release): split shell lines outside quotes and comments Splitting on separators anywhere in a line broke quoted strings apart, so echo 'note; bun run changelog:check-prerelease-notes ...' produced a bare command position and satisfied the ordering assertion. Track quote state while scanning, stop at an unquoted inline comment, and keep redirects from being read as pipes. Cover quoted separators, inline comments, trailing comments, and pipe-vs-redirect. --- src/workflow-test-helpers.test.ts | 12 ++++++ src/workflow-test-helpers.ts | 62 ++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) 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, '');