#!/usr/bin/env bash set -euo pipefail if [[ $# -ne 1 ]]; then printf 'Usage: %s \n' "${0##*/}" >&2 exit 2 fi change=$1 if [[ ! "$change" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then printf 'Invalid OpenSpec change name: %s\n' "$change" >&2 exit 2 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() { printf 'Planning verification failed: %s\n' "$1" >&2 exit 1 } require_file() { [[ -f "$1" ]] || fail "missing ${1#$workspace/}" } require_heading() { grep -Fqx -- "$2" "$1" || fail "${1#$workspace/} must contain heading: $2" } bash "$script_dir/verify-openspec-version.sh" "$openspec_root/.cli-version" >/dev/null 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 ! (cd "$specs_root" && openspec status --change "$change" --json --store wealth-ledger-specs >/dev/null); then fail "change $change is not resolvable through canonical store wealth-ledger-specs" fi for heading in '## Why' '## What Changes' '## Capabilities' '### New Capabilities' '### Modified Capabilities' '## Impact'; do require_heading "$proposal_file" "$heading" done temporary=$(mktemp -d) 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 wl_parse_delivery_map "$metadata_file" "$design_file" || exit 1 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" != *'