283 lines
11 KiB
Bash
Executable File
283 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ $# -ne 1 ]]; then
|
|
printf 'Usage: %s <change-name>\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" != *'<!--'* ]] || 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"
|
|
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
|