fix(#4109): rewrap unquoted DISPATCH_SLUGS-shaped consumption to survive zsh (#4116)

* test(#4109): add zsh regression coverage for gate-check and invoke_reviewers dispatch

Extends tests/review-plan-coverage-manifest.test.cjs to cover the two
DISPATCH_SLUGS consumption sites the existing #3301 coverage-check tests
don't reach: the write_reviews gate-check block (ALL_LANES_SKIPPED /
TOTAL_LANE_FAILURE counting) and invoke_reviewers' dispatch + join loops.
Both extract the real shipped bash from review.md and run it under bash and
zsh, same convention as the existing coverage-check rows. Expected RED under
zsh pre-fix, GREEN post-fix.

* fix(#4109): rewrap unquoted DISPATCH_SLUGS-shaped consumption to survive zsh

Bash word-splits an unquoted scalar on IFS by default; zsh does not (no
setopt SH_WORD_SPLIT anywhere in these files), so a scalar accumulated from
multiple space-separated tokens and then consumed via bare `for x in $VAR`
collapses onto one bogus iteration under zsh whenever it holds 2+ tokens.

Fixes all four review.md sites reported by #4108/#4109 (invoke_reviewers
dispatch + join loops, the gate-check block, and the #3301 coverage-check
block), plus the identical pattern found by a repo-wide sweep in
complete-milestone.md, code-review.md, pr-branch.md, sync-skills.md, and
execute-phase/steps/per-plan-worktree-gate.md. Each site is rewrapped in
unquoted command substitution (`$(printf '%s' "$VAR")`), which re-splits
identically under both shells regardless of SH_WORD_SPLIT — the same
mechanism that already made the accumulator-building loops in these files
shell-safe.

* test(#4109): warn loudly when the zsh probe fails instead of skipping silently

detectShells() drops the zsh test lane whenever a live zsh probe fails (e.g.
on a CI runner without zsh installed), and a dropped lane reads identically
to a passing one in the suite's own output — exactly the blind spot that let
#4109's bug class ship undetected. Both copies of this helper (this file and
its byte-identical duplicate in review-build-prompt-optional-sections.test.cjs)
now emit a greppable console.warn when the probe fails, so a run without zsh
reads as "zsh coverage unknown" rather than "all lanes green".

* ci(#4109): gate workflows/*.md's embedded bash blocks in lint:ci

Adds scripts/lint-workflow-shellcheck.cjs, wired into npm run lint:ci, so
this bug class can't land undetected a third time. Two independent checks
run over every ```bash block in gsd-core/workflows/**/*.md:

- ShellCheck (new `shellcheck` devDependency, downloads and caches the real
  koalaman/shellcheck binary) catches the general unquoted-expansion/
  word-splitting family in argument position. 212 pre-existing findings
  across the tree are absorbed into scripts/lint-workflow-shellcheck-baseline.json
  (matched on {file, code, message}, not line number, so unrelated edits
  elsewhere in a file can't spuriously un-baseline anything) — fixing all of
  them is out of scope for this issue; only NEW findings fail the build.
- A custom structural check specifically for #4109's own shape: ShellCheck
  does not flag a bare `for x in $VAR` word-list — it treats that as an
  intentional idiom under any ruleset (confirmed empirically). This check
  does, and gates the build on any occurrence, with zero tolerance (no
  baseline) since every known site was already swept and fixed on this
  branch.

* test(#4109): update pr-branch cherry-pick loop test anchor for the zsh fix

extractPickLoop()'s PICK_LOOP_MARKER located the create_pr_branch cherry-pick
loop by the literal substring "for HASH in $INCLUDED_COMMITS", which no
longer appears verbatim after this issue's fix rewrapped that loop in
unquoted command substitution. Updates the anchor (and its error message,
now derived from the same constant instead of duplicating stale text) to the
new literal form. No behavior change to the extraction logic itself.

Emitted-Drift-Ack-Growth: review.md — #4109's fix adds explanatory comments at 4 sites; net code is functionally equivalent, comment expansion grows the file
Emitted-Drift-Ack-Growth: complete-milestone.md — #4109's fix adds explanatory comments at 3 sites documenting the zsh word-splitting divergence
Emitted-Drift-Ack-Growth: code-review.md — #4109's fix adds an explanatory comment documenting the zsh word-splitting divergence
Emitted-Drift-Ack-Growth: pr-branch.md — #4109's fix adds explanatory comments at 3 sites (TRANSIENT_DIRS, INCLUDED_COMMITS, FILTER_PATHS)
Emitted-Drift-Ack-Growth: sync-skills.md — #4109's fix adds explanatory comments at 2 sites (CREATE_LIST/UPDATE_LIST, REMOVE_LIST)

* test(#4109): cover lint-workflow-shellcheck parsers + add timeout

Adds tests/lint-workflow-shellcheck.test.cjs covering the hand-rolled
parser/logic functions exported by scripts/lint-workflow-shellcheck.cjs
(stripCommandSubstitutions, stripShellComments, substitutePlaceholders,
extractForLoops, findBareForLoopSplits, findingKey, partitionAgainstBaseline)
that shipped with zero coverage — CLAUDE.md requires at least one fast-check
property test for parsers, included here (bare/braced forms always flagged
naming the variable; quoted/substituted/literal forms never are).

Also bounds runShellcheck's subprocess with a 60s timeout: the `shellcheck`
npm package's own API has no timeout option and internally blocks on a
synchronous spawnSync, so this reimplements the binary resolve/download step
via the package's own exported config/download and calls spawnSync directly
with a native timeout. And guards the CLI entry point with
`if (require.main === module)`, matching this repo's sibling dual-purpose
lint scripts — without it, requiring the module for its exported functions
(as the new test file does) also triggered a live ShellCheck run as a side
effect.

* docs(#4109): add changeset for the zsh word-splitting fix

* chore(#4109): backfill changeset PR number (#4116)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-31 18:08:06 -04:00
committed by GitHub
parent 1a358ce0fd
commit e242d85c92
15 changed files with 3641 additions and 20 deletions

View File

@@ -140,7 +140,10 @@ if [ -z "$FILES_OVERRIDE" ]; then
REVIEW_FILES=()
if [ -n "$SUMMARIES" ]; then
for summary in $SUMMARIES; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for summary in $(printf '%s' "$SUMMARIES"); do
# Extract key_files.created and key_files.modified using node for reliable YAML parsing
# This avoids fragile awk parsing that breaks on indentation differences
EXTRACTED=$(node -e "

View File

@@ -775,7 +775,10 @@ CURRENT_BRANCH=$(git branch --show-current)
git checkout ${BASE_BRANCH}
if [ "$BRANCHING_STRATEGY" = "phase" ]; then
for branch in $PHASE_BRANCHES; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for branch in $(printf '%s' "$PHASE_BRANCHES"); do
git merge --squash "$branch"
# Strip .planning/ from staging if commit_docs is false
if [ "$COMMIT_DOCS" = "false" ]; then
@@ -804,7 +807,10 @@ CURRENT_BRANCH=$(git branch --show-current)
git checkout ${BASE_BRANCH}
if [ "$BRANCHING_STRATEGY" = "phase" ]; then
for branch in $PHASE_BRANCHES; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for branch in $(printf '%s' "$PHASE_BRANCHES"); do
git merge --no-ff --no-commit "$branch"
# Strip .planning/ from staging if commit_docs is false
if [ "$COMMIT_DOCS" = "false" ]; then
@@ -830,7 +836,10 @@ git checkout "$CURRENT_BRANCH"
```bash
if [ "$BRANCHING_STRATEGY" = "phase" ]; then
for branch in $PHASE_BRANCHES; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for branch in $(printf '%s' "$PHASE_BRANCHES"); do
git branch -d "$branch" 2>/dev/null || git branch -D "$branch"
done
fi

View File

@@ -48,12 +48,18 @@ if [ -n "$SUBMODULE_PATHS" ] && [ "$USE_WORKTREES_FOR_PLAN" != "false" ]; then
# submodule "vendor/foo".
INTERSECT=""
set -f # disable globbing while iterating literal patterns
for sm_raw in $SUBMODULE_PATHS; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for sm_raw in $(printf '%s' "$SUBMODULE_PATHS"); do
# Normalize submodule path: strip ./ prefix and trailing /
sm="${sm_raw#./}"
sm="${sm%/}"
[ -z "$sm" ] && continue
for pf_raw in $PLAN_SCOPE_PATHS; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for pf_raw in $(printf '%s' "$PLAN_SCOPE_PATHS"); do
# Normalize planned path the same way
pf="${pf_raw#./}"
pf="${pf%/}"

View File

@@ -261,7 +261,10 @@ if [ "$PR_STRICT" = "true" ]; then
FILTER_PATHS=".planning/"
FORBIDDEN_RE="^\.planning/"
else
FILTER_PATHS=$(for d in $TRANSIENT_DIRS; do printf '.planning/%s/ ' "$d"; done)
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
FILTER_PATHS=$(for d in $(printf '%s' "$TRANSIENT_DIRS"); do printf '.planning/%s/ ' "$d"; done)
FORBIDDEN_RE="^\.planning/($(echo "$TRANSIENT_DIRS" | tr ' ' '|'))/"
fi
```
@@ -322,12 +325,15 @@ touching the same planning path makes `git cherry-pick` abort with *"untracked w
files would be overwritten by merge"*, and every remaining commit is silently dropped.
```bash
for HASH in $INCLUDED_COMMITS; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for HASH in $(printf '%s' "$INCLUDED_COMMITS"); do
# A modify/delete conflict on a filtered path is EXPECTED and is resolved below — the
# filtered path is absent from HEAD by construction. Do not treat it as a failure here.
git cherry-pick --no-commit "$HASH" || true
for P in $FILTER_PATHS; do
for P in $(printf '%s' "$FILTER_PATHS"); do
git rm -r -f -q --ignore-unmatch -- "$P" 2>/dev/null || true
git checkout HEAD -- "$P" 2>/dev/null || true
done

View File

@@ -490,7 +490,14 @@ for SLUG in $(echo "$SELECTED_REVIEWERS" | tr ',' ' '); do
DISPATCH_SLUGS="$DISPATCH_SLUGS $SLUG"
done
for SLUG in $DISPATCH_SLUGS; do
# Rewrapped through unquoted command substitution, not consumed as a bare
# `$DISPATCH_SLUGS`: bash word-splits an unquoted scalar on IFS by default,
# but zsh does not, so a bare re-split collapsed every slug onto one
# iteration under zsh whenever 2+ reviewers were selected (gsd-core#4109).
# Unquoted `$(...)` re-splits identically under both shells regardless of
# `SH_WORD_SPLIT` — same reason the accumulator-building loop above already
# works under both.
for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
if [ "$PARALLEL_LANES" = "true" ]; then
run_review_lane "$SLUG" &
else
@@ -508,7 +515,9 @@ wait
# produces is byte-identical to the one a sequential run produces. This is post-join and therefore
# single-threaded, so `>>` here is safe. A lane that was budget-skipped, or that never started,
# leaves no result file and correctly contributes no line.
for SLUG in $DISPATCH_SLUGS; do
# Rewrapped through unquoted command substitution (gsd-core#4109) — see the
# dispatch loop above for why a bare `$DISPATCH_SLUGS` collapses under zsh.
for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
LANE_RESULT="$RUN_DIR/gsd-review-lane-result-$SLUG.json"
if [ -f "$LANE_RESULT" ]; then
cat "$LANE_RESULT" >> "$RUN_DIR/gsd-review-lane-results.jsonl"
@@ -569,7 +578,10 @@ if [ "${LANE_LINES:-0}" -eq 0 ]; then
# failure stub does not. If a slug has no stub at all, it is not a skip.
DISPATCHED_COUNT=0
SKIPPED_COUNT=0
for SLUG in $DISPATCH_SLUGS; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$DISPATCH_SLUGS` word-splits under bash but not zsh, collapsing every
# slug onto one iteration there whenever 2+ reviewers were selected.
for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1))
STUB="$RUN_DIR/gsd-review-$SLUG.md"
if [ -f "$STUB" ] && grep -q "review skipped: prompt budget" "$STUB" 2>/dev/null; then
@@ -627,7 +639,10 @@ for SLUG in $(echo "$SELECTED_REVIEWERS" | tr ',' ' '); do
DISPATCH_SLUGS="$DISPATCH_SLUGS $SLUG"
done
for SLUG in $DISPATCH_SLUGS; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$DISPATCH_SLUGS` word-splits under bash but not zsh, collapsing every
# slug onto one iteration there whenever 2+ reviewers were selected.
for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
[ "$SLUG" = "coderabbit" ] && continue
REVIEW_FILE="$RUN_DIR/gsd-review-$SLUG.md"
[ -f "$REVIEW_FILE" ] || continue

View File

@@ -237,12 +237,18 @@ mkdir -p "$DEST_ROOT"
# empty. If per-runtime conversion is ever wired in, this is where it would go;
# until then the cp -r must never run for a destination != source.
for SKILL in $CREATE_LIST $UPDATE_LIST; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for SKILL in $(printf '%s' "$CREATE_LIST") $(printf '%s' "$UPDATE_LIST"); do
rm -rf "$DEST_ROOT/$SKILL"
cp -r "$SRC_SKILLS_ROOT/$SKILL" "$DEST_ROOT/$SKILL"
done
for SKILL in $REMOVE_LIST; do
# Rewrapped through unquoted command substitution (gsd-core#4109): a bare
# `$VAR` word-splits under bash but not zsh, collapsing every element onto
# one iteration there.
for SKILL in $(printf '%s' "$REMOVE_LIST"); do
rm -rf "$DEST_ROOT/$SKILL"
done
```