fix(#2962): enable nullglob in for-glob shell blocks for zsh portability (#3087)

* fix(#2962): enable nullglob in for-glob shell blocks for zsh portability

Workflow shell blocks are fenced bash but execute in the user's login shell
(zsh on macOS). zsh's nomatch default aborts the WHOLE block on an unmatched
glob in a for-list (not just skipping the command), silently bypassing every
statement after it — including the verify-phase decision-coverage gate,
whose optional *-CONTEXT.md lookup used the unsafe for-list form so the
DECISION_RESULT= assignment on the next line never ran under zsh.

Fix: prepend a portable nullglob shim to every bash block containing a
for-glob loop:
  shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
Each command no-ops (stderr suppressed) in the shell that doesn't recognize
it; the matching shell enables nullglob so an unmatched glob expands to
nothing and the loop body is skipped cleanly. Verified locally: both zsh and
bash now reach end-of-block (rc=0) on a no-match glob; bash matched-case
behavior unchanged.

14 blocks across 7 files: verify-phase.md (4, incl. the decision-coverage
gate), review.md, execute-phase.md, resume-project.md, complete-milestone.md,
audit-milestone.md, gsd-integration-checker.md, gsd-plan-checker.md (3).
Closes the zsh bypass of the #2770 fix.

* chore(#2962): add changeset fragment

* chore(#2962): backfill changeset PR number 3087

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-05 15:42:51 -04:00
committed by GitHub
parent 7203011400
commit 589a9b29b0
11 changed files with 49 additions and 10 deletions

View File

@@ -124,6 +124,9 @@ For each phase's VERIFICATION.md, extract the expanded requirements table:
For each phase's SUMMARY.md, extract `requirements-completed` from YAML frontmatter:
```bash
# #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
for summary in .planning/phases/*-*/*-SUMMARY.md; do
[ -e "$summary" ] || continue
gsd_run query summary-extract "$summary" --fields requirements_completed --pick requirements_completed

View File

@@ -219,6 +219,9 @@ Milestone Stats:
Extract one-liners from SUMMARY.md files using summary-extract:
```bash
# #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
# For each phase in milestone, extract one-liner
for summary in .planning/phases/*-*/*-SUMMARY.md; do
[ -e "$summary" ] || continue

View File

@@ -1486,12 +1486,13 @@ Copy failure must NOT block phase completion.
After `update_roadmap`, moves todos whose `resolves_phase` matches to `completed/`.
```bash
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
PHASE_NUM="${PHASE_NUMBER}"
PENDING_DIR=".planning/todos/pending"
COMPLETED_DIR=".planning/todos/completed"
mkdir -p "$COMPLETED_DIR"
# "05"=="5" (#2576).
#2576
normalize_phase_num() {
local p="${1//\"/}"; printf '%s' "$p" | sed 's/^0*\([0-9]\)/\1/'
}
@@ -1500,7 +1501,6 @@ PHASE_NUM_NORM=$(normalize_phase_num "$PHASE_NUM")
CLOSED=()
for TODO_FILE in "$PENDING_DIR"/*.md; do
[ -f "$TODO_FILE" ] || continue
# resolves_phase from first frontmatter block
RP=$(awk '/^---/{c++;next} c==1 && /^resolves_phase:/{print $2;exit} c==2{exit}' "$TODO_FILE" 2>/dev/null || true)
RP_NORM=$(normalize_phase_num "$RP")
if [ -n "$RP_NORM" ] && [ "$RP_NORM" = "$PHASE_NUM_NORM" ]; then

View File

@@ -64,6 +64,9 @@ cat .planning/PROJECT.md
Look for incomplete work that needs attention:
```bash
# #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
# Check for structured handoff (preferred — machine-readable)
cat .planning/HANDOFF.json 2>/dev/null || true

View File

@@ -242,6 +242,9 @@ Write to a temp file: `{run_dir}/gsd-review-prompt.md`
Also write individual section files so the budget tool can re-trim per reviewer:
```bash
# #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
RUN_DIR="{run_dir}" # from gather_context
# Write individual section files for per-reviewer budget trimming
@@ -312,6 +315,9 @@ If `section_manifest` is `null` or `"reviewer-instances-note-2"` is in its `incl
Lanes run **sequentially, not in parallel** — concurrent invocation trips provider rate limits.
```bash
# #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
RUN_DIR="{run_dir}"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
# SELECTED_REVIEWERS is the comma-separated result of reviewer selection (ADR-0011 precedence:

View File

@@ -57,6 +57,7 @@ Extract **phase goal** from ROADMAP.md (the outcome to verify, not tasks), **req
Use `gsd-tools.cjs query` verify handlers (or legacy gsd-tools) to extract must_haves from each PLAN:
```bash
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
for plan in "$PHASE_DIR"/*-PLAN.md; do
MUST_HAVES=$(gsd_run query frontmatter.get "$plan" --field must_haves)
echo "=== $plan ===" && echo "$MUST_HAVES"
@@ -126,6 +127,7 @@ For each truth: identify supporting artifacts → check artifact status → chec
Use `gsd-tools.cjs query verify.artifacts` (or legacy gsd-tools) for artifact verification against must_haves in each PLAN:
```bash
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
for plan in "$PHASE_DIR"/*-PLAN.md; do
ARTIFACT_RESULT=$(gsd_run query verify.artifacts "$plan")
echo "=== $plan ===" && echo "$ARTIFACT_RESULT"
@@ -169,6 +171,7 @@ wiring or leftover code from plan revisions.
Use `gsd-tools.cjs query verify.key-links` (or legacy gsd-tools) for key link verification against must_haves in each PLAN:
```bash
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
for plan in "$PHASE_DIR"/*-PLAN.md; do
LINKS_RESULT=$(gsd_run query verify.key-links "$plan")
echo "=== $plan ===" && echo "$LINKS_RESULT"
@@ -227,13 +230,7 @@ no `<decisions>` block.
```bash
GATE_CFG=$(gsd_run query config-get workflow.context_coverage_gate 2>/dev/null || echo "true")
if [ "$GATE_CFG" != "false" ]; then
# Discover the phase CONTEXT.md via glob expansion rather than `ls | head`
# (review F17 / ShellCheck SC2012). Globs preserve filenames containing
# spaces and avoid an extra subprocess.
CONTEXT_PATH=""
for f in "${PHASE_DIR}"/*-CONTEXT.md; do
[ -e "$f" ] && CONTEXT_PATH="$f" && break
done
CONTEXT_PATH=$(ls "${PHASE_DIR}"/*-CONTEXT.md 2>/dev/null | head -1) # #2962: not a for-glob (zsh aborts)
DECISION_RESULT=$(gsd_run query check.decision-coverage-verify "${PHASE_DIR}" "${CONTEXT_PATH}")
fi
```