From 0997039965ec67379a1ae450160775ba3f9b00d6 Mon Sep 17 00:00:00 2001 From: JD Sanders Date: Fri, 24 Jul 2026 11:26:21 -0500 Subject: [PATCH] fix(fm-brief): remove heredoc-in-command-substitution that breaks bash 3.2 (#958) fm-brief.sh failed to parse under stock macOS bash 3.2.57, so no brief could be scaffolded on a default macOS firstmate. Bash 3.2 misreads a lone apostrophe inside a heredoc body nested in $(...) as an unterminated single-quoted string and aborts with "unexpected EOF while looking for matching `)'". #945 added a line containing "firstmate's" to a DOD=$(cat <> redirection heredoc (not wrapped in $(...)), and the unguarded Herdr section is built with printf like its --herdr-lab counterpart. Both quoted and unquoted heredoc delimiters trip the 3.2 bug, so the quoted Herdr <<'EOF' was a latent trap too. Brief content is byte-for-byte unchanged across all modes; only the shell construction changed. Regression test parses fm-brief.sh under every available bash (the 3.2 variant is invisible to a modern-bash bash -n) and adds a version-independent structural guard that the command-substitution-around-heredoc pattern does not return. --- bin/fm-brief.sh | 101 +++++++++++++++++++++++------------------ tests/fm-brief.test.sh | 60 ++++++++++++++++++------ 2 files changed, 102 insertions(+), 59 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 00ea34ddab..54fd93998a 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -217,13 +217,15 @@ HERDR_SECTION=$(printf '%s\n' \ 'Never bypass the helper, even for a read-only lifecycle probe or cleanup after failure.' \ 'The captain fleet uses the running `default` session.') else -HERDR_SECTION=$(cat <<'EOF' -# Herdr lifecycle declaration - NOT ENABLED -**HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later. -If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch. -Do not add Herdr lifecycle commands to this unguarded brief by hand. -EOF -) +# Built with printf rather than HERDR_SECTION=$(cat <<'EOF' ...): a heredoc body +# nested in $(...) trips the bash 3.2 apostrophe parser bug (issue #958), and a +# quoted delimiter does not save it. printf is not wrapped in $(...) at parse time. +# shellcheck disable=SC2016 # single quotes are deliberate: backtick-wrapped literals must reach the reading agent verbatim, not expand at scaffold time. +HERDR_SECTION=$(printf '%s\n' \ +'# Herdr lifecycle declaration - NOT ENABLED' \ +'**HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later.' \ +'If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch.' \ +'Do not add Herdr lifecycle commands to this unguarded brief by hand.') fi if [ "$KIND" = scout ]; then @@ -285,51 +287,15 @@ case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' - DOD=$(cat <> "$BRIEF" <> "$BRIEF" <> "$BRIEF" < file <> file <&1); rc=$? - expect_code 0 "$rc" "bash -n bin/fm-brief.sh must parse cleanly (got: $out)" - [ -z "$out" ] || fail "bash -n bin/fm-brief.sh emitted unexpected output: $out" - pass "fm-brief.sh: bash -n succeeds" + local bash_bin out rc seen="" + for bash_bin in bash /bin/bash /usr/bin/bash /opt/homebrew/bin/bash /usr/local/bin/bash; do + command -v "$bash_bin" >/dev/null 2>&1 || continue + # Resolve to a real path so we do not parse the same binary twice. + local resolved; resolved=$(command -v "$bash_bin") + case " $seen " in *" $resolved "*) continue ;; esac + seen="$seen $resolved" + out=$("$bash_bin" -n "$ROOT/bin/fm-brief.sh" 2>&1); rc=$? + expect_code 0 "$rc" "$bash_bin -n bin/fm-brief.sh must parse cleanly (got: $out)" + [ -z "$out" ] || fail "$bash_bin -n bin/fm-brief.sh emitted unexpected output: $out" + done + pass "fm-brief.sh: bash -n succeeds on every available bash (incl. stock 3.2)" +} + +# Version-independent guard on the durable fix: the command-substitution-around- +# heredoc pattern that caused #958 must not return. `bash -n` under a modern bash +# would not catch its reintroduction, so pin it structurally instead. A heredoc +# fed to a plain `cat > "$FILE"` / `cat >> "$FILE"` redirection is fine; only a +# heredoc wrapped in `$(...)` (e.g. `VAR=$(cat <