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.
This commit is contained in:
sudacode
2026-08-23 04:20:26 -07:00
parent 2bfb72a09e
commit 107c6d1b06
2 changed files with 72 additions and 2 deletions
+12
View File
@@ -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' }),
+60 -2
View File
@@ -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(/;|&&|\|\||(?<![0-9<>])\|(?!\|)/)
splitCommandSeparators(line)
.map((segment) => {
let candidate = segment.trim();
let stripped = candidate.replace(COMMAND_PREFIX, '');