#!/usr/bin/env bash set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) workspace=$(cd "$script_dir/../.." && pwd -P) verifier="$script_dir/verify-openspec-planning.sh" changes_root="$workspace/specs/openspec/changes" source_change="$changes_root/harden-openspec-sdd-quality" if [[ ! -d "$source_change" ]]; then source_change="$changes_root/archive/2026-08-26-harden-openspec-sdd-quality" fi prefix="planning-fixture-$$" created_changes=('') cleanup() { local change for change in "${created_changes[@]}"; do [[ -n "$change" ]] || continue rm -rf "$changes_root/$change" done } trap cleanup EXIT if [[ ! -x "$verifier" ]]; then printf 'expected executable planning verifier at %s\n' "$verifier" >&2 exit 1 fi make_change() { local suffix=$1 fixture_change="$prefix-$suffix" cp -R "$source_change" "$changes_root/$fixture_change" created_changes+=("$fixture_change") } run_verifier() { bash "$verifier" "$1" } expect_failure() { local change=$1 local expected=$2 local expected_status=${3:-1} local output local status set +e output=$(run_verifier "$change" 2>&1) status=$? set -e if [[ $status -eq 0 ]]; then printf 'expected planning verification to fail for %s\n' "$change" >&2 exit 1 fi if [[ $status -ne $expected_status ]]; then printf 'expected exit %s for %s, got %s:\n%s\n' "$expected_status" "$change" "$status" "$output" >&2 exit 1 fi if [[ "$output" != *'Planning artifact-validation failed:'* ]]; then printf 'expected artifact-validation category for %s, got:\n%s\n' "$change" "$output" >&2 exit 1 fi if [[ "$output" != *"$expected"* ]]; then printf 'expected failure containing %q, got:\n%s\n' "$expected" "$output" >&2 exit 1 fi } make_change proposal proposal_change=$fixture_change rm -rf "$changes_root/$proposal_change/specs" rm -f "$changes_root/$proposal_change/design.md" "$changes_root/$proposal_change/tasks.md" proposal_output=$(run_verifier "$proposal_change") [[ "$proposal_output" == *'Planning stage verified: proposal'* ]] [[ "$proposal_output" == *'Full change validation pending: specs, design, tasks'* ]] set +e invocation_output=$(bash "$verifier" 'INVALID CHANGE' 2>&1) invocation_status=$? set -e if [[ $invocation_status -ne 2 || "$invocation_output" != *'Planning invocation failed:'* ]]; then printf 'expected invalid-name invocation failure, got exit %s:\n%s\n' "$invocation_status" "$invocation_output" >&2 exit 1 fi set +e missing_change_output=$(bash "$verifier" "$prefix-missing" 2>&1) missing_change_status=$? set -e if [[ $missing_change_status -ne 2 || "$missing_change_output" != *'Planning invocation failed:'* ]]; then printf 'expected missing-change invocation failure, got exit %s:\n%s\n' "$missing_change_status" "$missing_change_output" >&2 exit 1 fi set +e environment_output=$(PATH=/usr/bin:/bin bash "$verifier" "$proposal_change" 2>&1) environment_status=$? set -e if [[ $environment_status -ne 3 || "$environment_output" != *'Planning environment failed:'* ]]; then printf 'expected environment failure, got exit %s:\n%s\n' "$environment_status" "$environment_output" >&2 exit 1 fi make_change skip-conflict skip_conflict=$fixture_change rm -rf "$changes_root/$skip_conflict/specs" rm -f "$changes_root/$skip_conflict/design.md" "$changes_root/$skip_conflict/tasks.md" printf 'skip_specs: true\n' >> "$changes_root/$skip_conflict/.openspec.yaml" expect_failure "$skip_conflict" 'skip_specs: true cannot be combined with declared capabilities' make_change specs specs_change=$fixture_change rm -f "$changes_root/$specs_change/design.md" "$changes_root/$specs_change/tasks.md" specs_output=$(run_verifier "$specs_change") [[ "$specs_output" == *'Planning stage verified: specs'* ]] [[ "$specs_output" == *'Full change validation pending: design, tasks'* ]] make_change capability-mismatch capability_mismatch=$fixture_change mv "$changes_root/$capability_mismatch/specs/audit-remediation-workflow" "$changes_root/$capability_mismatch/specs/not-declared" expect_failure "$capability_mismatch" 'delta capability is absent from Proposal: not-declared' make_change design design_change=$fixture_change rm -f "$changes_root/$design_change/tasks.md" design_output=$(run_verifier "$design_change") [[ "$design_output" == *'Planning stage verified: design'* ]] [[ "$design_output" == *'Full change validation pending: tasks'* ]] make_change required-rollback required_rollback=$fixture_change rm -f "$changes_root/$required_rollback/tasks.md" perl -0pi -e 's/\| Status \| `N\/A` \|/| Status | `required` |/; s/(\| Verification \|)/| Trigger | Deployment fails the local gate. |\n| Actions | Restore the previous local state. |\n| Data safety | No application data changes. |\n$1/' "$changes_root/$required_rollback/design.md" required_output=$(run_verifier "$required_rollback") [[ "$required_output" == *'Planning stage verified: design'* ]] perl -0pi -e 's/^\| Data safety \|.*\n//m' "$changes_root/$required_rollback/design.md" expect_failure "$required_rollback" 'Rollback Contract required status needs exactly one Data safety row' make_change tasks tasks_change=$fixture_change tasks_output=$(run_verifier "$tasks_change") [[ "$tasks_output" == *'Planning stage verified: tasks'* ]] [[ "$tasks_output" == *'Delta requirement coverage verified: 9/9'* ]] [[ "$tasks_output" == *'Full planning structure verified; semantic approval remains human.'* ]] for artifact in proposal specs design tasks; do instructions=$(cd "$workspace/specs" && openspec instructions "$artifact" --change "$tasks_change" --store wealth-ledger-specs --json) node -e ' const result = JSON.parse(require("node:fs").readFileSync(0, "utf8")); if (!result.instruction.trimStart().startsWith("Critical Rules:")) process.exit(1); ' <<< "$instructions" done specs_instructions=$(cd "$workspace/specs" && openspec instructions specs --change "$tasks_change" --store wealth-ledger-specs --json) node -e ' const result = JSON.parse(require("node:fs").readFileSync(0, "utf8")); const activeOperation = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/m; if (activeOperation.test(result.template)) process.exit(1); for (const token of ["Purpose", "ADDED", "MODIFIED", "REMOVED", "RENAMED"]) { if (!result.template.includes(token)) process.exit(1); } ' <<< "$specs_instructions" operation_change="$prefix-operation-neutral" cp -R "$script_dir/fixtures/operation-neutral-change" "$changes_root/$operation_change" created_changes+=("$operation_change") operation_output=$(run_verifier "$operation_change") if [[ "$operation_output" != *'Planning stage verified: specs'* ]]; then printf 'expected controlled operation fixture to verify, got:\n%s\n' "$operation_output" >&2 exit 1 fi for operation in ADDED MODIFIED REMOVED RENAMED; do if [[ $(grep -Fxc "## $operation Requirements" "$changes_root/$operation_change/specs/workspace-artifact-hygiene/spec.md") -ne 1 ]]; then printf 'expected exactly one controlled %s operation section\n' "$operation" >&2 exit 1 fi done # A completed fixture receives OpenSpec's terminal archive message instead of # the schema's Apply instruction, so leave one task pending for this contract. perl -0pi -e 's/^- \[[xX]\]/- [ ]/m' "$changes_root/$tasks_change/tasks.md" apply_instructions=$(cd "$workspace/specs" && openspec instructions apply --change "$tasks_change" --store wealth-ledger-specs --json) node -e ' const result = JSON.parse(require("node:fs").readFileSync(0, "utf8")); for (const token of ["contextFiles", "AGENTS.md", "planned Verification", "Feedback routing", "Механическая правка", "Requirement wording"]) { if (!result.instruction.includes(token)) process.exit(1); } ' <<< "$apply_instructions" design_instructions=$(cd "$workspace/specs" && openspec instructions design --change "$tasks_change" --store wealth-ledger-specs --json) node -e ' const result = JSON.parse(require("node:fs").readFileSync(0, "utf8")); if (!result.instruction.includes("инвалидируй downstream approvals")) process.exit(1); ' <<< "$design_instructions" make_change invalid-area invalid_area=$fixture_change perl -0pi -e 's/\[specs\]/[frontend]/' "$changes_root/$invalid_area/tasks.md" expect_failure "$invalid_area" 'task 1.1 uses frontend, but Delivery Map marks it N/A' make_change unknown-reference unknown_reference=$fixture_change perl -0pi -e 's/`Planning artifact проверяется до запроса approval`/`Несуществующее требование`/' "$changes_root/$unknown_reference/tasks.md" expect_failure "$unknown_reference" 'unknown requirement reference' make_change uncovered-requirement uncovered_requirement=$fixture_change sed -i '' '/`audit-remediation-workflow` — `Каждое audit claim получает доказательный disposition`/s/`audit-remediation-workflow` — `Каждое audit claim получает доказательный disposition`/`audit-remediation-workflow` — `Реестр remediation отделён от снимка аудита`/' "$changes_root/$uncovered_requirement/tasks.md" expect_failure "$uncovered_requirement" 'uncovered Requirement: audit-remediation-workflow — Каждое audit claim получает доказательный disposition' make_change missing-metadata missing_metadata=$fixture_change perl -0pi -e 's/^ - \*\*Verification:\*\*.*\n//m' "$changes_root/$missing_metadata/tasks.md" expect_failure "$missing_metadata" 'task 1.1 must contain exactly one Verification line' make_change unresolved-na-placeholder unresolved_na_placeholder=$fixture_change perl -0pi -e 's/^ - \*\*Covers:\*\*.*$/ - **Covers:** `N\/A` — /m' "$changes_root/$unresolved_na_placeholder/tasks.md" expect_failure "$unresolved_na_placeholder" 'task 1.1 has unresolved N/A rationale placeholder' make_change empty-na empty_na=$fixture_change perl -0pi -e 's/^ - \*\*Covers:\*\*.*$/ - **Covers:** `N\/A` — /m' "$changes_root/$empty_na/tasks.md" expect_failure "$empty_na" 'task 1.1 has empty N/A rationale' make_change separator-na separator_na=$fixture_change perl -0pi -e 's/^ - \*\*Covers:\*\*.*$/ - **Covers:** `N\/A` — —/m' "$changes_root/$separator_na/tasks.md" expect_failure "$separator_na" 'task 1.1 has empty N/A rationale' make_change malformed-rollback malformed_rollback=$fixture_change sed -i '' '/| Status | `N\/A` |/d' "$changes_root/$malformed_rollback/design.md" expect_failure "$malformed_rollback" 'Rollback Contract must contain exactly one Status row' printf 'verify-openspec-planning tests passed\n'