Files
specs/scripts/verify-openspec-planning.sh
T

304 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
invocation_fail() {
printf 'Planning invocation failed: %s\n' "$1" >&2
exit 2
}
environment_fail() {
printf 'Planning environment failed: %s\n' "$1" >&2
exit 3
}
artifact_fail() {
printf 'Planning artifact-validation failed: %s\n' "$1" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
invocation_fail "usage: ${0##*/} <change-name>"
fi
change=$1
if [[ ! "$change" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
invocation_fail "invalid OpenSpec change name: $change"
fi
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
workspace=${WEALTH_LEDGER_WORKSPACE:-$(cd "$script_dir/../.." && pwd -P)}
specs_root="$workspace/specs"
openspec_root="$specs_root/openspec"
change_dir="$openspec_root/changes/$change"
metadata_file="$change_dir/.openspec.yaml"
proposal_file="$change_dir/proposal.md"
design_file="$change_dir/design.md"
tasks_file="$change_dir/tasks.md"
# shellcheck source=lib/delivery-map.sh
source "$script_dir/lib/delivery-map.sh"
fail() {
artifact_fail "$1"
}
require_file() {
[[ -f "$1" ]] || fail "missing ${1#$workspace/}"
}
require_heading() {
grep -Fqx -- "$2" "$1" || fail "${1#$workspace/} must contain heading: $2"
}
if ! version_output=$(bash "$script_dir/verify-openspec-version.sh" "$openspec_root/.cli-version" 2>&1); then
environment_fail "$version_output"
fi
[[ -d "$change_dir" ]] || invocation_fail "change $change is absent from canonical store wealth-ledger-specs"
require_file "$metadata_file"
require_file "$proposal_file"
if ! grep -Fqx 'schema: wealth-ledger-sdd' "$metadata_file"; then
fail '.openspec.yaml must select schema wealth-ledger-sdd'
fi
if ! grep -Eq '^goal: .+' "$metadata_file"; then
fail '.openspec.yaml must contain a non-empty goal'
fi
if ! status_output=$(cd "$specs_root" && openspec status --change "$change" --json --store wealth-ledger-specs 2>&1); then
invocation_fail "change $change is not resolvable through canonical store wealth-ledger-specs: $status_output"
fi
for heading in '## Why' '## What Changes' '## Capabilities' '### New Capabilities' '### Modified Capabilities' '## Impact'; do
require_heading "$proposal_file" "$heading"
done
if ! temporary=$(mktemp -d); then
environment_fail 'cannot create temporary planning-verification directory'
fi
trap 'rm -rf "$temporary"' EXIT
declared_capabilities="$temporary/declared-capabilities.tsv"
actual_capabilities="$temporary/actual-capabilities.txt"
delta_requirements="$temporary/delta-requirements.tsv"
covered_requirements="$temporary/covered-requirements.tsv"
: > "$declared_capabilities"
: > "$actual_capabilities"
: > "$delta_requirements"
: > "$covered_requirements"
awk '
/^### New Capabilities$/ { kind = "new"; next }
/^### Modified Capabilities$/ { kind = "modified"; next }
/^## / || /^### / { kind = "" }
kind != "" && /^- `[^`]+`:/ {
line = $0
sub(/^- `/, "", line)
sub(/`:.*/, "", line)
print kind "\t" line
}
' "$proposal_file" > "$declared_capabilities"
while IFS=$'\t' read -r kind capability; do
[[ -n "$capability" ]] || continue
if [[ "$capability" == /* || "$capability" == *'..'* || ! "$capability" =~ ^[a-z0-9]+([/-][a-z0-9]+)*$ ]]; then
fail "invalid capability path in Proposal: $capability"
fi
if [[ "$kind" == 'modified' && ! -f "$openspec_root/specs/$capability/spec.md" ]]; then
fail "modified capability does not exist in canonical specs: $capability"
fi
done < "$declared_capabilities"
skip_specs=0
if grep -Fqx 'skip_specs: true' "$metadata_file"; then
skip_specs=1
if [[ -s "$declared_capabilities" ]]; then
fail 'skip_specs: true cannot be combined with declared capabilities'
fi
fi
has_specs=0
if [[ -d "$change_dir/specs" ]] && find "$change_dir/specs" -type f -name spec.md -print -quit | grep -q .; then
[[ $skip_specs -eq 0 ]] || fail 'skip_specs: true cannot be combined with delta spec files'
has_specs=1
while IFS= read -r file; do
capability=${file#"$change_dir/specs/"}
capability=${capability%/spec.md}
printf '%s\n' "$capability" >> "$actual_capabilities"
awk -v capability="$capability" '/^### Requirement: / { sub(/^### Requirement: /, ""); print capability "\t" $0 }' "$file" >> "$delta_requirements"
done < <(find "$change_dir/specs" -type f -name spec.md | sort)
while IFS= read -r capability; do
if ! awk -F '\t' -v capability="$capability" '$2 == capability { found = 1 } END { exit(found ? 0 : 1) }' "$declared_capabilities"; then
fail "delta capability is absent from Proposal: $capability"
fi
done < "$actual_capabilities"
while IFS=$'\t' read -r _ capability; do
[[ -n "$capability" ]] || continue
grep -Fqx "$capability" "$actual_capabilities" || fail "Proposal capability has no delta spec: $capability"
done < "$declared_capabilities"
if ! (cd "$specs_root" && openspec validate "$change" --type change --strict --store wealth-ledger-specs >/dev/null); then
fail 'strict Delta Specs validation failed'
fi
elif [[ ! -s "$declared_capabilities" && $skip_specs -eq 0 ]]; then
fail 'Proposal must declare capabilities or .openspec.yaml must set skip_specs: true'
fi
has_design=0
if [[ -f "$design_file" ]]; then
has_design=1
[[ $has_specs -eq 1 || $skip_specs -eq 1 ]] || fail 'Design exists before Delta Specs'
for heading in '## Context' '## Goals / Non-Goals' '## Decisions' '## Delivery Map' '### Contract Gate Decision' '## Risks / Trade-offs' '## Migration Plan' '### Rollback Contract'; do
require_heading "$design_file" "$heading"
done
delivery_map_error="$temporary/delivery-map-error.txt"
if ! wl_parse_delivery_map "$metadata_file" "$design_file" 2>"$delivery_map_error"; then
artifact_fail "$(<"$delivery_map_error")"
fi
rollback_status_count=$(awk -F'|' '$2 ~ /^[[:space:]]*Status[[:space:]]*$/ { count++ } END { print count + 0 }' "$design_file")
[[ "$rollback_status_count" -eq 1 ]] || fail 'Rollback Contract must contain exactly one Status row'
rollback_status=$(awk -F'|' '$2 ~ /^[[:space:]]*Status[[:space:]]*$/ { value=$3; gsub(/^[[:space:]`]+|[[:space:]`]+$/, "", value); print value }' "$design_file")
if [[ "$rollback_status" == 'N/A' ]]; then
rollback_reason=$(awk -F'|' '$2 ~ /^[[:space:]]*Reason[[:space:]]*$/ { value=$3; gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value }' "$design_file")
[[ -n "$rollback_reason" && "$rollback_reason" != '-' && "$rollback_reason" != '—' && "$rollback_reason" != *'<!--'* ]] || fail 'Rollback Contract N/A requires a concrete Reason row'
elif [[ "$rollback_status" == 'required' ]]; then
for field in Trigger Actions 'Data safety' Verification; do
count=$(awk -F'|' -v field="$field" '$2 ~ "^[[:space:]]*" field "[[:space:]]*$" { count++ } END { print count + 0 }' "$design_file")
[[ "$count" -eq 1 ]] || fail "Rollback Contract required status needs exactly one $field row"
done
else
fail "Rollback Contract Status must be required or N/A, got: $rollback_status"
fi
fi
has_tasks=0
if [[ -f "$tasks_file" ]]; then
has_tasks=1
[[ $has_design -eq 1 ]] || fail 'Tasks exists before Design'
task_number=''
task_area=''
covers_count=0
verification_count=0
covers_is_na=0
task_count=0
area_task_counts=(0 0 0 0)
area_gate_counts=(0 0 0 0)
finalize_task() {
[[ -n "$task_number" ]] || return 0
[[ "$covers_count" -eq 1 ]] || fail "task $task_number must contain exactly one Covers line"
[[ "$verification_count" -eq 1 ]] || fail "task $task_number must contain exactly one Verification line"
if [[ "$task_area" == 'workspace' && "$covers_is_na" -ne 1 ]]; then
fail "workspace task $task_number must use a concrete N/A coverage rationale"
fi
}
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == '- [ ] '* || "$line" == '- [x] '* || "$line" == '- [X] '* ]]; then
finalize_task
if [[ ! "$line" =~ ^-\ \[[\ xX]\]\ ([0-9]+\.[0-9]+)\ \[(frontend|backend|specs|docs|workspace)\]\ .+ ]]; then
fail "invalid task checkbox shape: $line"
fi
task_number=${BASH_REMATCH[1]}
task_area=${BASH_REMATCH[2]}
covers_count=0
verification_count=0
covers_is_na=0
task_count=$((task_count + 1))
if [[ "$task_area" != 'workspace' ]]; then
index=$(wl_area_index "$task_area")
if [[ "${wl_map_statuses[$index]}" != 'affected' ]]; then
fail "task $task_number uses $task_area, but Delivery Map marks it ${wl_map_statuses[$index]}"
fi
area_task_counts[$index]=$((area_task_counts[$index] + 1))
fi
continue
fi
if [[ "$line" == ' - **Covers:** '* ]]; then
[[ -n "$task_number" ]] || fail 'Covers metadata appears before a task'
covers_count=$((covers_count + 1))
payload=${line#' - **Covers:** '}
if [[ "$payload" == \`N/A\`\ —\ * ]]; then
reason=${payload#\`N/A\`\ —\ }
[[ -n "$reason" && "$reason" != '-' && "$reason" != '—' ]] || fail "task $task_number has empty N/A rationale"
[[ "$reason" != *'<!--'* && "$reason" != *'-->'* ]] || fail "task $task_number has unresolved N/A rationale placeholder"
covers_is_na=1
else
IFS=';' read -r -a references <<< "$payload"
for reference in "${references[@]}"; do
reference=$(wl_trim "$reference")
if ! grep -Eq '^`[^`]+` — `[^`]+`$' <<< "$reference"; then
fail "task $task_number has malformed requirement reference: $reference"
fi
clean=${reference//\`/}
capability=${clean%% — *}
requirement=${clean#* — }
key="$capability"$'\t'"$requirement"
if ! grep -Fqx "$key" "$delta_requirements"; then
fail "task $task_number has unknown requirement reference: $capability — $requirement"
fi
printf '%s\n' "$key" >> "$covered_requirements"
done
fi
continue
fi
if [[ "$line" == ' - **Verification:** '* ]]; then
[[ -n "$task_number" ]] || fail 'Verification metadata appears before a task'
verification_count=$((verification_count + 1))
verification=${line#' - **Verification:** '}
[[ -n "$verification" ]] || fail "task $task_number has empty Verification"
if [[ "$task_area" != 'workspace' ]]; then
index=$(wl_area_index "$task_area")
case "$task_area" in
frontend|backend|docs)
[[ "$verification" == *'npm run verify'* ]] && area_gate_counts[$index]=$((area_gate_counts[$index] + 1))
;;
specs)
if [[ "$verification" == *'strict change validation'* || "$verification" == *'openspec validate'* ]]; then
area_gate_counts[$index]=$((area_gate_counts[$index] + 1))
fi
;;
esac
fi
fi
done < "$tasks_file"
finalize_task
[[ "$task_count" -gt 0 ]] || fail 'Tasks contains no tracked checkboxes'
for index in 0 1 2 3; do
if [[ "${wl_affected_flags[$index]}" -eq 1 ]]; then
[[ "${area_task_counts[$index]}" -gt 0 ]] || fail "affected area ${wl_areas[$index]} has no owned task"
[[ "${area_gate_counts[$index]}" -gt 0 ]] || fail "affected area ${wl_areas[$index]} has no canonical gate in Verification"
fi
done
while IFS= read -r key; do
[[ -n "$key" ]] || continue
if ! grep -Fqx "$key" "$covered_requirements"; then
capability=${key%%$'\t'*}
requirement=${key#*$'\t'}
fail "uncovered Requirement: $capability — $requirement"
fi
done < "$delta_requirements"
requirement_count=$(wc -l < "$delta_requirements" | tr -d ' ')
printf 'Delta requirement coverage verified: %s/%s\n' "$requirement_count" "$requirement_count"
fi
if [[ $has_tasks -eq 1 ]]; then
printf 'Planning stage verified: tasks\n'
printf 'Full planning structure verified; semantic approval remains human.\n'
elif [[ $has_design -eq 1 ]]; then
printf 'Planning stage verified: design\n'
printf 'Full change validation pending: tasks\n'
elif [[ $has_specs -eq 1 ]]; then
printf 'Planning stage verified: specs\n'
printf 'Full change validation pending: design, tasks\n'
else
printf 'Planning stage verified: proposal\n'
printf 'Full change validation pending: specs, design, tasks\n'
fi