#!/usr/bin/env bash # ============================================================================= # L0 static check (offline, no cluster) — spec-immutability-enforced # Serves: R-TEST-06 ("AI agents MUST NOT modify the specifications (this docs/ tree, # the requirements, or accepted ADRs). Specs are changed only by the human # operator/architect." — spec docs/00-vision-and-scope.md.) # # HOW A GOVERNANCE RULE IS MADE TESTABLE # ------------------------------------- # "An agent must not do X" cannot be proven by observing that no agent did X today. # What CAN be proven — and is what actually protects the spec — is that the MECHANISM # which detects and blocks a spec modification works, and is wired into the merge gate # as a blocking stage. So this test drives the real `ci/spec-immutability.sh` against # HERMETIC THROWAWAY spec repositories and asserts its exit-code behaviour. # # THE REAL SPEC REPO IS NEVER TOUCHED. Every case runs against a fixture git repo under # mktemp, with SPEC_REPO pointed at it and a COPY of ci/ (so the fixture's SPEC_PIN can # be set without editing the tracked one). Nothing here writes outside its temp dir — # which is itself the R-TEST-06 discipline this test exists to defend. # # GIVEN the enforcement mechanism (ci/spec-immutability.sh + ci/SPEC_PIN), # WHEN it is run against a spec repo in each state, # THEN (1) clean and at the pinned SHA -> exit 0 (pass) # (2) an UNCOMMITTED edit under docs/ -> exit 1 (blocks the merge) # (3) an uncommitted edit under decisions/ -> exit 1 (accepted ADRs are spec too) # (4) a NEW COMMIT (HEAD drifted from pin) -> exit 1 (an "adopted" change must be # an explicit reviewed SPEC_PIN bump) # (5) an untracked file added to the spec -> exit 1 (porcelain catches additions) # (6) the spec repo absent -> exit 3 (honest SKIP, never green) # (7) an empty/comment-only SPEC_PIN -> exit 1 (an unpinned gate is no gate) # AND (8) the tracked ci/SPEC_PIN really is a 40-hex commit SHA, and # (9) the gate wires spec-immutability as a stage whose FAIL sets the merge block — # i.e. the mechanism above is load-bearing, not dead code. # # WHAT THIS DOES *NOT* PROVE (honest gap): # - That the Gitea server actually REQUIRES this status check before merge. Branch # protection is operator configuration outside the repo; nothing in-repo can assert it. # - Intent: an agent could still open a spec PR. The rule is enforced by the gate # blocking it, which is exactly what (1)-(9) assert; it is not enforced by prediction. # ============================================================================= set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root CI_SRC="${ROOT}/ci" PASS=0 FAILED=0 note() { printf ' ok %s\n' "$*"; PASS=$((PASS + 1)); } bad() { printf ' FAIL %s\n' "$*" >&2; FAILED=$((FAILED + 1)); } WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT # --- a hermetic stand-in for the spec repo ----------------------------------------- # Mirrors the real spec's shape: a docs/ tree and a decisions/ tree, one commit. make_spec_repo() { local d="$1" mkdir -p "${d}/docs" "${d}/decisions" # Fixture prose deliberately carries NO R-* token: ci/touched-requirements.sh scans the # non-comment lines of changed files, so an example ID here would be attributed to the # change as a touched requirement that does not exist in the registry. printf 'normative spec text (fixture)\n' > "${d}/docs/00-vision-and-scope.md" printf 'accepted decision\n' > "${d}/decisions/adr-0001-example.md" git -C "$d" init -q -b main git -C "$d" config user.email spec@example.invalid git -C "$d" config user.name spec git -C "$d" config commit.gpgsign false # throwaway: never sign, ignore host gpg config git -C "$d" add -A git -C "$d" commit -qm "spec baseline" } # A COPY of ci/ whose SPEC_PIN we may set freely. The script resolves CI_DIR from its own # location and sources lib.sh + versions.env from there, so a copy is self-contained. make_ci_copy() { local dst="$1" pin="$2" mkdir -p "$dst" cp "${CI_SRC}/spec-immutability.sh" "${CI_SRC}/lib.sh" "${CI_SRC}/versions.env" "$dst/" printf '%s\n' "$pin" > "${dst}/SPEC_PIN" chmod +x "${dst}/spec-immutability.sh" } # run_case run_case() { local want="$1" desc="$2" spec="$3" pin="$4" rc=0 local cidir; cidir="$(mktemp -d -p "$WORK" ci.XXXXXX)" make_ci_copy "$cidir" "$pin" SPEC_REPO="$spec" REPO_ROOT="$WORK" "${cidir}/spec-immutability.sh" >/dev/null 2>&1 || rc=$? if [ "$rc" = "$want" ]; then note "${desc} -> exit ${rc}"; else bad "${desc}: expected exit ${want}, got ${rc}"; fi } for f in spec-immutability.sh lib.sh versions.env SPEC_PIN; do if [ -f "${CI_SRC}/${f}" ]; then note "ci/${f} present"; else bad "ci/${f} is missing"; fi done SPEC="${WORK}/spec" make_spec_repo "$SPEC" PINNED="$(git -C "$SPEC" rev-parse HEAD)" # (1) clean + at the pin -> pass. run_case 0 "clean spec at the pinned SHA passes" "$SPEC" "$PINNED" # (2) an uncommitted edit under docs/ -> block. printf 'normative spec text REWRITTEN BY AN AGENT\n' > "${SPEC}/docs/00-vision-and-scope.md" run_case 1 "uncommitted edit under docs/ BLOCKS" "$SPEC" "$PINNED" git -C "$SPEC" checkout -q -- docs/00-vision-and-scope.md # (3) an uncommitted edit to an accepted ADR -> block (decisions/ is spec too). printf 'reversed decision\n' > "${SPEC}/decisions/adr-0001-example.md" run_case 1 "uncommitted edit under decisions/ BLOCKS" "$SPEC" "$PINNED" git -C "$SPEC" checkout -q -- decisions/adr-0001-example.md # (5) an untracked addition to the spec tree -> block. printf 'a spec doc an agent invented\n' > "${SPEC}/docs/99-invented.md" run_case 1 "untracked file added to the spec BLOCKS" "$SPEC" "$PINNED" rm -f "${SPEC}/docs/99-invented.md" # clean again -> still passes (proves cases 2/3/5 failed for the edit, not permanently). run_case 0 "spec restored to clean passes again" "$SPEC" "$PINNED" # (4) a committed spec change that the pin has not adopted -> block. printf 'an extra normative line added by an agent\n' >> "${SPEC}/docs/00-vision-and-scope.md" git -C "$SPEC" add -A git -C "$SPEC" commit -qm "agent edits the spec" DRIFTED="$(git -C "$SPEC" rev-parse HEAD)" run_case 1 "committed spec drift from the pinned SHA BLOCKS" "$SPEC" "$PINNED" # ...and adopting it EXPLICITLY (a reviewed SPEC_PIN bump) is the only way to green. run_case 0 "an explicit SPEC_PIN bump adopts the advance" "$SPEC" "$DRIFTED" # (6) spec repo unavailable -> honest SKIP (exit 3), never a pass. run_case 3 "absent spec repo SKIPS (exit 3), never green" "${WORK}/no-such-spec" "$PINNED" # (7) an unpinned gate is not a gate. run_case 1 "empty SPEC_PIN FAILS closed" "$SPEC" "# only a comment, no SHA" # (8) the tracked pin is a real commit SHA. REAL_PIN="$(grep -vE '^\s*#' "${CI_SRC}/SPEC_PIN" | tr -d '[:space:]')" if printf '%s' "$REAL_PIN" | grep -Eq '^[0-9a-f]{40}$'; then note "ci/SPEC_PIN is a 40-hex commit SHA" else bad "ci/SPEC_PIN is not a 40-hex commit SHA: '${REAL_PIN}'" fi # (9) the mechanism is load-bearing: the merge gate runs it as a stage AND a FAIL there # sets the block. Asserted against the gate source because running the whole gate # from inside a conformance check would recurse into this very check. GATE="${CI_SRC}/gate.sh" if grep -Eq '^[[:space:]]*run_stage[[:space:]]+spec-immutability' "$GATE"; then note "ci/gate.sh runs spec-immutability as a gate stage" else bad "ci/gate.sh does not run spec-immutability as a stage — the gate is not wired" fi if awk '/OUTCOME\[spec-immutability\]/,/^esac/' "$GATE" | grep -Eq '^[[:space:]]*fail\).*BLOCK=1'; then note "ci/gate.sh blocks the merge when spec-immutability FAILS" else bad "ci/gate.sh does not set the merge block on a spec-immutability failure" fi echo if [ "$FAILED" -eq 0 ]; then echo "check OK: spec-immutability-enforced — ${PASS} assertions (R-TEST-06)" exit 0 fi echo "CHECK FAIL (spec-immutability-enforced): ${FAILED} failed, ${PASS} passed" >&2 exit 1