diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 95e80ea..949e512 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adept", - "version": "2.12.3", + "version": "2.12.4", "description": "Development-workflow skills: design, TDD, adversarial review, shipping, and campaign orchestration for Claude Code and Codex.", "author": { "name": "David Christensen" diff --git a/.github/scripts/check-records-test.sh b/.github/scripts/check-records-test.sh index 5f1f992..5ddf235 100755 --- a/.github/scripts/check-records-test.sh +++ b/.github/scripts/check-records-test.sh @@ -1254,6 +1254,48 @@ STUB run_case "gate file witness faults, not silently unprotected" 1 E-GATE-SCAN "$d" \ BASE_SHA="$b" PATH="$stub_bin:$PATH" + # A checker basename is repository-controlled and may look like a git-grep option. Quoting + # keeps it one shell argument; only `-e` keeps Git from parsing it as an option and silently + # omitting the workflow that protects the renamed checker. + d="$SCRATCH/option_shaped_checker" + new_repo "$d" + mv "$d/.github/scripts/check-records.sh" "$d/.github/scripts/--cached" + sed 's/check-records\.sh/--cached/' "$d/.github/workflows/records.yml" >"$d/.workflow" + mv "$d/.workflow" "$d/.github/workflows/records.yml" + write_record "$d" "0001-valid.md" + git -C "$d" add -A + git -C "$d" commit -qm base + b=$(base_of "$d") + rm "$d/.github/workflows/records.yml" + cat >"$d/.github/scripts/check-records.sh" <<'STUB' +#!/usr/bin/env bash +exec "$(dirname "$0")/--cached" "$@" +STUB + chmod +x "$d/.github/scripts/check-records.sh" + run_case "option-shaped checker still protects its workflow" 1 E-GATE-GONE "$d" \ + BASE_SHA="$b" + + # A workflow search that could not read the base ref must not silently shrink the gate's + # protected path set. The checker itself and its suite still give gate_paths two paths, so + # this fixture would otherwise pass while omitting the workflow whose scan faulted. + stub_bin="$SCRATCH/git-gate-paths-fault-bin" + mkdir -p "$stub_bin" + cat >"$stub_bin/git" <&2 + exit 128 + fi + done +fi +exec "$real_git" "\$@" +STUB + chmod +x "$stub_bin/git" + run_case "gate workflow search faults, not silently omitted" 1 E-GATE-PATHS-SCAN "$d" \ + BASE_SHA="$b" PATH="$stub_bin:$PATH" + # A base ref that predates the gate is the adoption PR, and it must not be red — and it # must say why, per the same discipline as every other rule: assert both the exit status # and which code fired. Bespoke rather than run_case: I-GATE-BOOTSTRAP is informational, @@ -1327,7 +1369,7 @@ STUB mkdir -p "$stub_bin" cat >"$stub_bin/git" <&2 diff --git a/.github/scripts/check-records.sh b/.github/scripts/check-records.sh index 19d8726..030543e 100755 --- a/.github/scripts/check-records.sh +++ b/.github/scripts/check-records.sh @@ -1159,7 +1159,13 @@ gate_existed_at() { # profile — a repo with more than one profile would otherwise print every gate finding once # per profile in RECORD_PROFILES. check_gate_files() { - local base=$1 self successor successor_path gate_path_count=0 self_status still_status succ_status + local base=$1 paths paths_status=0 self successor successor_path gate_path_count=0 + local self_status still_status succ_status + paths=$(gate_paths "$base") || paths_status=$? + if [ "$paths_status" -ne 0 ]; then + err_full "E-GATE-PATHS-SCAN: $base: could not search the base ref's workflows for gate paths (git exit $paths_status)" + return + fi while IFS= read -r self; do [ -n "$self" ] || continue self_status=0 @@ -1210,7 +1216,7 @@ check_gate_files() { 1) err "E-GATE-GONE: $self was deleted or untracked — the gate cannot be removed by the change it gates" ;; *) err_full "E-GATE-SUCCESSOR-SCAN: $successor_path: could not read the index entry for $self's declared successor, so the rename is unverified (git exit $tracked_in_index_status)" ;; esac - done < <(gate_paths "$base" | sort -u) + done < <(printf '%s\n' "$paths" | sort -u) # An empty protected set means self-protection is off. That is the correct state for the # PR that installs the gate in a new repo, and a silent failure for one that renamed it — @@ -1249,11 +1255,11 @@ repo_relative() { # stopping it from mentioning the checker — swapping it for a symlink to an inert file does # exactly that. A repo driving the checker from something other than GitHub Actions yields # no workflow here, which is not an error; that file is just not protected. -# Emits paths only. It must not call err: callers read it through a process substitution, -# where an err would set failed=1 in a subshell and lose it — the same shape that once let a -# stray file print an error and still exit 0. The caller checks locatability itself. +# Emits paths only. It must not call err: callers capture its output and status separately, +# because an err inside that subshell would set failed=1 only in the discarded process. The +# caller reports a non-zero status before it reads any paths. gate_paths() { - local base=$1 rel profile old key new needle profiles_rel + local base=$1 rel profile old key new needle profiles_rel matches grep_status needles rel=$(repo_relative "$SELF_FILE") [ -n "$rel" ] && printf '%s\n' "$rel" @@ -1296,11 +1302,10 @@ gate_paths() { # it and left every profile silently unprotected. A directory merely absent from the ref # exits 0 with no output, so that case never needed the guard. # - # The `|| true` stays. gate_paths runs inside a process substitution, where err's - # assignment to `failed` lands in a discarded subshell -- it emits paths only, and its - # callers report. A damaged object store is therefore an accepted residual here rather than - # an oversight; giving this one function a fault channel means a private sentinel protocol - # between it and its caller, which ADR 0005 weighed and rejected. + # The `|| true` stays. The caller now captures gate_paths' status, but this listing still + # runs behind the process substitution feeding the loop, so its status cannot reach the + # function's return. Closing that residual means moving profile enumeration out of this + # shape; issue #89 scopes the workflow search below, not this listing. profiles_rel=$(repo_relative "$SELF_DIR/profiles") if [ -n "$profiles_rel" ]; then while IFS= read -r profile; do @@ -1332,26 +1337,36 @@ gate_paths() { # are generic enough to appear in an unrelated repo's workflow for reasons that have nothing # to do with this gate. # - # `sort -u` at the end: a workflow naming more than one needle (this repo's own workflow - # names both check-debt.sh and check-debt-test.sh in the same PR) would otherwise be - # emitted once per match, and check_gate_files would then report the same path's finding - # more than once. - { - printf '%s\n' "$(basename "$SELF_FILE")" - while IFS= read -r old; do - [ -n "$old" ] || continue - key=${old%%$'\t'*} - new=${old#*$'\t'} - key=${key##*/} - new=${new##*/} - case "$key" in - *.sh) [ "$key" = "$new" ] || printf '%s\n' "$key" ;; - esac - done <<<"$GATE_PREDECESSORS" - } | while IFS= read -r needle; do - git grep --no-color -lF "$needle" "$base" -- .github/workflows 2>/dev/null | - sed 's/^[^:]*://' || true - done | sort -u + # The caller sorts the complete result: a workflow naming more than one needle (this repo's + # own workflow names both check-debt.sh and check-debt-test.sh in the same PR) would otherwise + # be emitted once per match, and check_gate_files would report the same finding more than once. + needles=${SELF_FILE##*/} + while IFS= read -r old; do + [ -n "$old" ] || continue + key=${old%%$'\t'*} + new=${old#*$'\t'} + key=${key##*/} + new=${new##*/} + case "$key" in + *.sh) [ "$key" = "$new" ] || needles="$needles +$key" ;; + esac + done <<<"$GATE_PREDECESSORS" + + while IFS= read -r needle; do + grep_status=0 + matches=$(git grep --no-color -lF -e "$needle" "$base" -- .github/workflows 2>/dev/null) || + grep_status=$? + case $grep_status in + 0) + while IFS= read -r rel; do + printf '%s\n' "${rel#*:}" + done <<<"$matches" + ;; + 1) ;; + *) return "$grep_status" ;; + esac + done <<<"$needles" } # `git rev-parse --show-toplevel` exits non-zero for more than "you are not inside a git diff --git a/skills/tome-of-lore/assets/check-records-test.sh b/skills/tome-of-lore/assets/check-records-test.sh index 5f1f992..5ddf235 100755 --- a/skills/tome-of-lore/assets/check-records-test.sh +++ b/skills/tome-of-lore/assets/check-records-test.sh @@ -1254,6 +1254,48 @@ STUB run_case "gate file witness faults, not silently unprotected" 1 E-GATE-SCAN "$d" \ BASE_SHA="$b" PATH="$stub_bin:$PATH" + # A checker basename is repository-controlled and may look like a git-grep option. Quoting + # keeps it one shell argument; only `-e` keeps Git from parsing it as an option and silently + # omitting the workflow that protects the renamed checker. + d="$SCRATCH/option_shaped_checker" + new_repo "$d" + mv "$d/.github/scripts/check-records.sh" "$d/.github/scripts/--cached" + sed 's/check-records\.sh/--cached/' "$d/.github/workflows/records.yml" >"$d/.workflow" + mv "$d/.workflow" "$d/.github/workflows/records.yml" + write_record "$d" "0001-valid.md" + git -C "$d" add -A + git -C "$d" commit -qm base + b=$(base_of "$d") + rm "$d/.github/workflows/records.yml" + cat >"$d/.github/scripts/check-records.sh" <<'STUB' +#!/usr/bin/env bash +exec "$(dirname "$0")/--cached" "$@" +STUB + chmod +x "$d/.github/scripts/check-records.sh" + run_case "option-shaped checker still protects its workflow" 1 E-GATE-GONE "$d" \ + BASE_SHA="$b" + + # A workflow search that could not read the base ref must not silently shrink the gate's + # protected path set. The checker itself and its suite still give gate_paths two paths, so + # this fixture would otherwise pass while omitting the workflow whose scan faulted. + stub_bin="$SCRATCH/git-gate-paths-fault-bin" + mkdir -p "$stub_bin" + cat >"$stub_bin/git" <&2 + exit 128 + fi + done +fi +exec "$real_git" "\$@" +STUB + chmod +x "$stub_bin/git" + run_case "gate workflow search faults, not silently omitted" 1 E-GATE-PATHS-SCAN "$d" \ + BASE_SHA="$b" PATH="$stub_bin:$PATH" + # A base ref that predates the gate is the adoption PR, and it must not be red — and it # must say why, per the same discipline as every other rule: assert both the exit status # and which code fired. Bespoke rather than run_case: I-GATE-BOOTSTRAP is informational, @@ -1327,7 +1369,7 @@ STUB mkdir -p "$stub_bin" cat >"$stub_bin/git" <&2 diff --git a/skills/tome-of-lore/assets/check-records.sh b/skills/tome-of-lore/assets/check-records.sh index 19d8726..030543e 100755 --- a/skills/tome-of-lore/assets/check-records.sh +++ b/skills/tome-of-lore/assets/check-records.sh @@ -1159,7 +1159,13 @@ gate_existed_at() { # profile — a repo with more than one profile would otherwise print every gate finding once # per profile in RECORD_PROFILES. check_gate_files() { - local base=$1 self successor successor_path gate_path_count=0 self_status still_status succ_status + local base=$1 paths paths_status=0 self successor successor_path gate_path_count=0 + local self_status still_status succ_status + paths=$(gate_paths "$base") || paths_status=$? + if [ "$paths_status" -ne 0 ]; then + err_full "E-GATE-PATHS-SCAN: $base: could not search the base ref's workflows for gate paths (git exit $paths_status)" + return + fi while IFS= read -r self; do [ -n "$self" ] || continue self_status=0 @@ -1210,7 +1216,7 @@ check_gate_files() { 1) err "E-GATE-GONE: $self was deleted or untracked — the gate cannot be removed by the change it gates" ;; *) err_full "E-GATE-SUCCESSOR-SCAN: $successor_path: could not read the index entry for $self's declared successor, so the rename is unverified (git exit $tracked_in_index_status)" ;; esac - done < <(gate_paths "$base" | sort -u) + done < <(printf '%s\n' "$paths" | sort -u) # An empty protected set means self-protection is off. That is the correct state for the # PR that installs the gate in a new repo, and a silent failure for one that renamed it — @@ -1249,11 +1255,11 @@ repo_relative() { # stopping it from mentioning the checker — swapping it for a symlink to an inert file does # exactly that. A repo driving the checker from something other than GitHub Actions yields # no workflow here, which is not an error; that file is just not protected. -# Emits paths only. It must not call err: callers read it through a process substitution, -# where an err would set failed=1 in a subshell and lose it — the same shape that once let a -# stray file print an error and still exit 0. The caller checks locatability itself. +# Emits paths only. It must not call err: callers capture its output and status separately, +# because an err inside that subshell would set failed=1 only in the discarded process. The +# caller reports a non-zero status before it reads any paths. gate_paths() { - local base=$1 rel profile old key new needle profiles_rel + local base=$1 rel profile old key new needle profiles_rel matches grep_status needles rel=$(repo_relative "$SELF_FILE") [ -n "$rel" ] && printf '%s\n' "$rel" @@ -1296,11 +1302,10 @@ gate_paths() { # it and left every profile silently unprotected. A directory merely absent from the ref # exits 0 with no output, so that case never needed the guard. # - # The `|| true` stays. gate_paths runs inside a process substitution, where err's - # assignment to `failed` lands in a discarded subshell -- it emits paths only, and its - # callers report. A damaged object store is therefore an accepted residual here rather than - # an oversight; giving this one function a fault channel means a private sentinel protocol - # between it and its caller, which ADR 0005 weighed and rejected. + # The `|| true` stays. The caller now captures gate_paths' status, but this listing still + # runs behind the process substitution feeding the loop, so its status cannot reach the + # function's return. Closing that residual means moving profile enumeration out of this + # shape; issue #89 scopes the workflow search below, not this listing. profiles_rel=$(repo_relative "$SELF_DIR/profiles") if [ -n "$profiles_rel" ]; then while IFS= read -r profile; do @@ -1332,26 +1337,36 @@ gate_paths() { # are generic enough to appear in an unrelated repo's workflow for reasons that have nothing # to do with this gate. # - # `sort -u` at the end: a workflow naming more than one needle (this repo's own workflow - # names both check-debt.sh and check-debt-test.sh in the same PR) would otherwise be - # emitted once per match, and check_gate_files would then report the same path's finding - # more than once. - { - printf '%s\n' "$(basename "$SELF_FILE")" - while IFS= read -r old; do - [ -n "$old" ] || continue - key=${old%%$'\t'*} - new=${old#*$'\t'} - key=${key##*/} - new=${new##*/} - case "$key" in - *.sh) [ "$key" = "$new" ] || printf '%s\n' "$key" ;; - esac - done <<<"$GATE_PREDECESSORS" - } | while IFS= read -r needle; do - git grep --no-color -lF "$needle" "$base" -- .github/workflows 2>/dev/null | - sed 's/^[^:]*://' || true - done | sort -u + # The caller sorts the complete result: a workflow naming more than one needle (this repo's + # own workflow names both check-debt.sh and check-debt-test.sh in the same PR) would otherwise + # be emitted once per match, and check_gate_files would report the same finding more than once. + needles=${SELF_FILE##*/} + while IFS= read -r old; do + [ -n "$old" ] || continue + key=${old%%$'\t'*} + new=${old#*$'\t'} + key=${key##*/} + new=${new##*/} + case "$key" in + *.sh) [ "$key" = "$new" ] || needles="$needles +$key" ;; + esac + done <<<"$GATE_PREDECESSORS" + + while IFS= read -r needle; do + grep_status=0 + matches=$(git grep --no-color -lF -e "$needle" "$base" -- .github/workflows 2>/dev/null) || + grep_status=$? + case $grep_status in + 0) + while IFS= read -r rel; do + printf '%s\n' "${rel#*:}" + done <<<"$matches" + ;; + 1) ;; + *) return "$grep_status" ;; + esac + done <<<"$needles" } # `git rev-parse --show-toplevel` exits non-zero for more than "you are not inside a git