feat: harden OpenSpec SDD workflow
This commit is contained in:
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user