From eac1f12b1abbe0dfaa7d01a289621a0c342ef8c6 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sat, 13 Jun 2026 17:13:02 -0500 Subject: [PATCH 1/4] fix(qa-gate): resolve per-plan VERIFICATION.md for single-plan phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A passing single-plan phase whose QA artifact was written with the per-plan name `{NN}-{MM}-VERIFICATION.md` was read by the gate as missing: `resolve-verification-path.sh` only resolved the phase-level `{NN}-VERIFICATION.md`, the brownfield plain `VERIFICATION.md`, or per-wave `{NN}-VERIFICATION-wave*.md` — never the per-plan name. The gate then reported `qa_gate_writer=missing` / `qa_gate_result=missing` and forced a spurious `QA_RERUN_REQUIRED` on a phase that already passed. Teach `phase_level_path()` to adopt a per-plan verification as a last-resort fallback when exactly one `{NN}-{MM}-VERIFICATION.md` exists and no phase-level, plain, or wave-level artifact is present. Multiple per-plan files are ambiguous (the integration artifact is authoritative) and fall through to the canonical phase-level name, so multi-plan phases are unchanged. Fixing the shared resolver fixes every consumer (`phase`/`current`/`authoritative`) and the QA gate in one place. Tests: - resolve-verification-path.bats: single per-plan adopted; multiple per-plan ambiguous -> phase-level; on-disk phase-level wins; wave files win. - qa-result-gate.bats: single-plan phase with only a per-plan VERIFICATION.md routes PROCEED_TO_UAT. The secondary `git mv -k` no-op on untracked artifacts noted in the issue has no locus in the current tree (no `git mv` anywhere); it was a live manual-recovery action, not committed code. Closes #653. Co-Authored-By: Claude Opus 4.8 --- scripts/resolve-verification-path.sh | 19 +++++++- tests/qa-result-gate.bats | 27 +++++++++++ tests/resolve-verification-path.bats | 71 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/scripts/resolve-verification-path.sh b/scripts/resolve-verification-path.sh index 2855dc2a9..1adc160c8 100755 --- a/scripts/resolve-verification-path.sh +++ b/scripts/resolve-verification-path.sh @@ -46,7 +46,7 @@ if [ ! -d "$PHASE_DIR" ]; then fi phase_level_path() { - local canonical_name canonical_path base phase_num phase_prefix wave_files + local canonical_name canonical_path base phase_num phase_prefix wave_files plan_files plan_count canonical_name=$(bash "$SCRIPT_DIR/resolve-artifact-path.sh" verification "$PHASE_DIR" 2>/dev/null || true) if [ -n "$canonical_name" ]; then @@ -81,6 +81,23 @@ phase_level_path() { fi fi + # Per-plan single-plan-phase fallback (issue #653): when no phase-level, plain, + # or wave-level artifact exists on disk but the writer produced exactly one + # per-plan {NN}-{MM}-VERIFICATION.md (the single-plan-phase case), adopt it so + # the QA gate does not see the phase as unverified and force a spurious re-run. + # Only a single match is adopted — multiple per-plan files are ambiguous and + # fall through to the canonical phase-level name (the integration artifact). + if [ -n "$phase_prefix" ]; then + plan_files=$(ls -1 "$PHASE_DIR/${phase_prefix}-"[0-9][0-9]"-VERIFICATION.md" 2>/dev/null | (sort -V 2>/dev/null || sort) || true) + if [ -n "$plan_files" ]; then + plan_count=$(printf '%s\n' "$plan_files" | grep -c . || true) + if [ "${plan_count:-0}" -eq 1 ]; then + printf '%s\n' "$plan_files" | head -1 + return 0 + fi + fi + fi + if [ -n "$canonical_name" ]; then echo "$PHASE_DIR/$canonical_name" return 0 diff --git a/tests/qa-result-gate.bats b/tests/qa-result-gate.bats index 240addda8..8f8df79b2 100644 --- a/tests/qa-result-gate.bats +++ b/tests/qa-result-gate.bats @@ -997,6 +997,33 @@ VERIF [[ "$output" == *"qa_gate_writer=write-verification.sh"* ]] } +@test "single-plan phase with only a per-plan {NN}-{MM}-VERIFICATION.md → PROCEED_TO_UAT (issue #653)" { + # Regression: a passing single-plan phase whose verification artifact was + # written with the per-plan name must not be read as missing and forced to + # re-run. Before the fix the gate auto-resolved the (absent) phase-level + # 01-VERIFICATION.md and reported qa_gate_writer=missing → QA_RERUN_REQUIRED. + : > "$PHASE_DIR/01-01-PLAN.md" + { + echo "---" + echo "phase: 01" + echo "result: PASS" + echo "passed: 33" + echo "failed: 0" + echo "total: 33" + echo "plans_verified: [01-01]" + echo "writer: write-verification.sh" + echo "---" + } > "$PHASE_DIR/01-01-VERIFICATION.md" + + run bash "$SCRIPT" "$PHASE_DIR" + + [ "$status" -eq 0 ] + [[ "$output" == *"qa_gate_routing=PROCEED_TO_UAT"* ]] + # Confirm the per-plan artifact was actually read (not a missing-file false pass) + [[ "$output" == *"qa_gate_writer=write-verification.sh"* ]] + [[ "$output" == *"qa_gate_result=PASS"* ]] +} + @test "brownfield fallback to plain VERIFICATION.md" { # Create a phase dir WITHOUT numbered prefix BROWNFIELD_DIR="$TEST_DIR/legacy-phase" diff --git a/tests/resolve-verification-path.bats b/tests/resolve-verification-path.bats index 029596764..dc93d2eaf 100644 --- a/tests/resolve-verification-path.bats +++ b/tests/resolve-verification-path.bats @@ -307,4 +307,75 @@ EOF [ "$status" -eq 0 ] [ -z "$output" ] +} + +# Issue #653: single-plan phase where the writer produced a per-plan +# {NN}-{MM}-VERIFICATION.md must resolve to that artifact, not a synthetic +# phase-level name the gate would read as missing. +@test "resolve-verification-path phase adopts the sole per-plan verification (issue #653 single-plan phase)" { + cat > "$PHASE_DIR/01-01-VERIFICATION.md" <<'EOF' +--- +result: PASS +plans_verified: [01-01] +--- +EOF + + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-01-VERIFICATION.md" ] +} + +@test "resolve-verification-path phase does NOT adopt per-plan files when more than one exists (ambiguous -> phase-level)" { + cat > "$PHASE_DIR/01-01-VERIFICATION.md" <<'EOF' +--- +result: PASS +--- +EOF + cat > "$PHASE_DIR/01-02-VERIFICATION.md" <<'EOF' +--- +result: PASS +--- +EOF + + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-VERIFICATION.md" ] +} + +@test "resolve-verification-path phase prefers an on-disk phase-level artifact over a per-plan one" { + cat > "$PHASE_DIR/01-VERIFICATION.md" <<'EOF' +--- +result: PASS +--- +EOF + cat > "$PHASE_DIR/01-01-VERIFICATION.md" <<'EOF' +--- +result: FAIL +--- +EOF + + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-VERIFICATION.md" ] +} + +@test "resolve-verification-path phase prefers wave files over a per-plan one" { + cat > "$PHASE_DIR/01-VERIFICATION-wave1.md" <<'EOF' +--- +result: PASS +--- +EOF + cat > "$PHASE_DIR/01-01-VERIFICATION.md" <<'EOF' +--- +result: FAIL +--- +EOF + + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-VERIFICATION-wave1.md" ] } \ No newline at end of file From cc8f6a47329aab8ad0ffcd10217be4aebfaa015c Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 15 Jun 2026 17:40:38 -0500 Subject: [PATCH 2/4] fix(qa-gate): address QA round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the findings from the round-1 QA review (read-only adversarial review of the per-plan VERIFICATION.md fix): - The per-plan fallback glob `[0-9][0-9]` missed 3-digit plan numbers (`{NN}-100-VERIFICATION.md` and beyond); the writer formats plan numbers with `printf %02d`, so the 100th+ plan yields 3 digits and the artifact was read as missing — the same #653 symptom at a different plan-number range. Widened to `[0-9][0-9]*` and replaced the `ls -1 ... | grep -c .` parse with a `nullglob` array (exact entry count, no `ls` parsing, no newline-in-filename ambiguity). - Added coverage in resolve-verification-path.bats: the zero-artifact fallthrough regression guard and a 3-digit per-plan adoption test. - Added the missing trailing newline to resolve-verification-path.bats. Co-Authored-By: Claude Opus 4.8 --- scripts/resolve-verification-path.sh | 22 +++++++++++++-------- tests/resolve-verification-path.bats | 29 +++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/scripts/resolve-verification-path.sh b/scripts/resolve-verification-path.sh index 1adc160c8..d874e501e 100755 --- a/scripts/resolve-verification-path.sh +++ b/scripts/resolve-verification-path.sh @@ -46,7 +46,7 @@ if [ ! -d "$PHASE_DIR" ]; then fi phase_level_path() { - local canonical_name canonical_path base phase_num phase_prefix wave_files plan_files plan_count + local canonical_name canonical_path base phase_num phase_prefix wave_files canonical_name=$(bash "$SCRIPT_DIR/resolve-artifact-path.sh" verification "$PHASE_DIR" 2>/dev/null || true) if [ -n "$canonical_name" ]; then @@ -88,13 +88,19 @@ phase_level_path() { # Only a single match is adopted — multiple per-plan files are ambiguous and # fall through to the canonical phase-level name (the integration artifact). if [ -n "$phase_prefix" ]; then - plan_files=$(ls -1 "$PHASE_DIR/${phase_prefix}-"[0-9][0-9]"-VERIFICATION.md" 2>/dev/null | (sort -V 2>/dev/null || sort) || true) - if [ -n "$plan_files" ]; then - plan_count=$(printf '%s\n' "$plan_files" | grep -c . || true) - if [ "${plan_count:-0}" -eq 1 ]; then - printf '%s\n' "$plan_files" | head -1 - return 0 - fi + # Match per-plan names with a >=2-digit MM component to mirror the writer's + # `printf %02d` plan numbering (resolve-artifact-path.sh), so 3-digit plan + # numbers (100+) are not silently missed. A nullglob array gives an exact + # entry count with no `ls` parsing and no newline-in-filename ambiguity. + local -a plan_files=() + local _nullglob_restore + _nullglob_restore=$(shopt -p nullglob 2>/dev/null || true) + shopt -s nullglob + plan_files=( "$PHASE_DIR/${phase_prefix}-"[0-9][0-9]*"-VERIFICATION.md" ) + eval "$_nullglob_restore" 2>/dev/null || true + if [ "${#plan_files[@]}" -eq 1 ]; then + printf '%s\n' "${plan_files[0]}" + return 0 fi fi diff --git a/tests/resolve-verification-path.bats b/tests/resolve-verification-path.bats index dc93d2eaf..e7d26ae82 100644 --- a/tests/resolve-verification-path.bats +++ b/tests/resolve-verification-path.bats @@ -378,4 +378,31 @@ EOF [ "$status" -eq 0 ] [ "$output" = "$PHASE_DIR/01-VERIFICATION-wave1.md" ] -} \ No newline at end of file +} + +# Regression guard: a numbered phase with NO verification artifact of any kind +# must fall through the per-plan block and return the synthetic phase-level +# name (the pre-existing behavior the #653 block must not disturb). +@test "resolve-verification-path phase returns the synthetic phase-level name when no artifact exists" { + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-VERIFICATION.md" ] +} + +# Issue #653 (round 1 hardening): the writer's `printf %02d` plan numbering +# yields 3-digit names for the 100th+ plan, so the per-plan fallback must adopt +# a sole {NN}-{MMM}-VERIFICATION.md, not just 2-digit MM. +@test "resolve-verification-path phase adopts a sole 3-digit per-plan verification (100+ plan numbers)" { + cat > "$PHASE_DIR/01-100-VERIFICATION.md" <<'EOF' +--- +result: PASS +plans_verified: [01-100] +--- +EOF + + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-100-VERIFICATION.md" ] +} From d1eebba979e79a3513e4e9ae8f5ab17040c46972 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 15 Jun 2026 17:46:42 -0500 Subject: [PATCH 3/4] fix(qa-gate): address QA round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 QA review flagged the round-1 per-plan glob `[0-9][0-9]*` as greedy: the trailing `*` also matched non-canonical names like `{NN}-01a-VERIFICATION.md` and `{NN}-01-extra-VERIFICATION.md`, which would then be adopted as the sole per-plan artifact. No writer in the codebase emits such names (MM is always `printf %02d`, no suffix), so it was latent rather than reachable — but loose. Tighten it: keep the nullglob array as a cheap prefilter, then re-filter with a strict regex `^{prefix}-[0-9][0-9]+-VERIFICATION\.md$` (>=2-digit, all-numeric MM) so over-matches are rejected before the single-match decision. 2- and 3-digit plan numbers are still adopted; non-numeric or extra-segment names fall through to the canonical phase-level name. Added a negative test asserting non-canonical per-plan names are not adopted. Co-Authored-By: Claude Opus 4.8 --- scripts/resolve-verification-path.sh | 23 ++++++++++++++++------- tests/resolve-verification-path.bats | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/scripts/resolve-verification-path.sh b/scripts/resolve-verification-path.sh index d874e501e..36a842460 100755 --- a/scripts/resolve-verification-path.sh +++ b/scripts/resolve-verification-path.sh @@ -88,16 +88,25 @@ phase_level_path() { # Only a single match is adopted — multiple per-plan files are ambiguous and # fall through to the canonical phase-level name (the integration artifact). if [ -n "$phase_prefix" ]; then - # Match per-plan names with a >=2-digit MM component to mirror the writer's - # `printf %02d` plan numbering (resolve-artifact-path.sh), so 3-digit plan - # numbers (100+) are not silently missed. A nullglob array gives an exact - # entry count with no `ls` parsing and no newline-in-filename ambiguity. - local -a plan_files=() - local _nullglob_restore + # Match per-plan names with a >=2-digit, all-numeric MM component to mirror + # the writer's `printf %02d` plan numbering (resolve-artifact-path.sh): so + # 3-digit plan numbers (100+) are matched, but non-canonical names such as + # `{NN}-01a-VERIFICATION.md` or `{NN}-01-extra-VERIFICATION.md` are not. + # A nullglob array gives an exact entry count with no `ls` parsing and no + # newline-in-filename ambiguity; the glob's `*` is greedy, so a strict regex + # re-filter rejects its over-matches before the single-match decision. + local -a plan_files=() _plan_matches=() + local _nullglob_restore _pf _pf_base _nullglob_restore=$(shopt -p nullglob 2>/dev/null || true) shopt -s nullglob - plan_files=( "$PHASE_DIR/${phase_prefix}-"[0-9][0-9]*"-VERIFICATION.md" ) + _plan_matches=( "$PHASE_DIR/${phase_prefix}-"[0-9][0-9]*"-VERIFICATION.md" ) eval "$_nullglob_restore" 2>/dev/null || true + for _pf in "${_plan_matches[@]}"; do + _pf_base=$(basename "$_pf") + if [[ "$_pf_base" =~ ^${phase_prefix}-[0-9][0-9]+-VERIFICATION\.md$ ]]; then + plan_files+=("$_pf") + fi + done if [ "${#plan_files[@]}" -eq 1 ]; then printf '%s\n' "${plan_files[0]}" return 0 diff --git a/tests/resolve-verification-path.bats b/tests/resolve-verification-path.bats index e7d26ae82..4923e0aeb 100644 --- a/tests/resolve-verification-path.bats +++ b/tests/resolve-verification-path.bats @@ -406,3 +406,25 @@ EOF [ "$status" -eq 0 ] [ "$output" = "$PHASE_DIR/01-100-VERIFICATION.md" ] } + +# Issue #653 (QA round 2): the per-plan glob is greedy; a strict regex re-filter +# must reject non-canonical names whose MM segment is not purely numeric (e.g. a +# trailing letter or an extra `-segment-`), so they are NOT mistaken for the sole +# per-plan artifact. Such names fall through to the canonical phase-level name. +@test "resolve-verification-path phase does NOT adopt a non-canonical per-plan name (over-match guard)" { + cat > "$PHASE_DIR/01-01a-VERIFICATION.md" <<'EOF' +--- +result: PASS +--- +EOF + cat > "$PHASE_DIR/01-01-extra-VERIFICATION.md" <<'EOF' +--- +result: PASS +--- +EOF + + run bash "$SCRIPTS_DIR/resolve-verification-path.sh" phase "$PHASE_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$PHASE_DIR/01-VERIFICATION.md" ] +} From 97c29a40adfa70b0a7ed96b062274a241afec4da Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 15 Jun 2026 17:51:22 -0500 Subject: [PATCH 4/4] fix(qa-gate): address QA round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 QA review came back fully clean — no findings at any severity. Verified: the round-2 regex re-filter closes the greedy-glob over-match; `phase_prefix` is provably digit-only (both derivation paths go through `printf %02d` / a `^[0-9]+$` guard), so its interpolation into the `[[ =~ ]]` ERE is injection-safe; the >=2-digit MM requirement, `set -euo pipefail` behavior, nullglob save/restore, basename handling, and precedence/ambiguity semantics all hold. shellcheck clean; both bats suites green (21 + 184, zero failures). Empty evidence commit per the QA review process (clean round). Co-Authored-By: Claude Opus 4.8