#!/usr/bin/env bash # check-contract-drift.sh — Enforce Constitution Principle V (contracts stay in lock-step). # # Fails when boundary-signature changes are detected under internal layers without # any update under contracts/*.md in the same diff range. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" log() { printf '[contract-drift] %s\n' "$*" } err() { printf '[contract-drift] ERROR: %s\n' "$*" >&2 } resolve_range() { if [[ -n "${AUGUR_CONTRACT_DRIFT_RANGE:-}" ]]; then printf '%s' "$AUGUR_CONTRACT_DRIFT_RANGE" return 0 fi if [[ -n "${GITHUB_BASE_REF:-}" ]]; then git fetch --no-tags --depth=1 origin "$GITHUB_BASE_REF" >/dev/null 2>&1 || true printf 'origin/%s...HEAD' "$GITHUB_BASE_REF" return 0 fi if [[ -n "${GITHUB_EVENT_BEFORE:-}" ]] && [[ -n "${GITHUB_SHA:-}" ]] && [[ "$GITHUB_EVENT_BEFORE" != "0000000000000000000000000000000000000000" ]]; then printf '%s...%s' "$GITHUB_EVENT_BEFORE" "$GITHUB_SHA" return 0 fi if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then printf 'HEAD~1...HEAD' return 0 fi printf '' } USE_WORKTREE="${AUGUR_CONTRACT_DRIFT_USE_WORKTREE:-0}" RANGE="" if [[ "$USE_WORKTREE" == "1" ]]; then log "Diff source: working tree (HEAD -> working tree)" changed_files="$(git diff --name-only)" else RANGE="$(resolve_range)" if [[ -z "$RANGE" ]]; then log "No diff range could be resolved; skipping contract drift check." exit 0 fi log "Diff range: $RANGE" changed_files="$(git diff --name-only "$RANGE")" fi if [[ -z "$changed_files" ]]; then log "No changed files in range; skipping." exit 0 fi if printf '%s\n' "$changed_files" | grep -Eq '^contracts/.*\.md$'; then log "Contract files changed in range; check passed." exit 0 fi # Boundary-sensitive files that define cross-layer contracts. boundary_files="$(printf '%s\n' "$changed_files" | grep -E '^internal/(cli|service|provider|storage|sync|model)/.*\.go$' || true)" if [[ -z "$boundary_files" ]]; then log "No boundary-sensitive Go files changed; check passed." exit 0 fi violations=() while IFS= read -r file; do [[ -z "$file" ]] && continue # Canonical model and provider interface are always contract-relevant. if [[ "$file" == "internal/model/conversation.go" ]] || [[ "$file" == "internal/provider/provider.go" ]]; then violations+=("$file") continue fi # Heuristic: exported symbol signature/shape changes in boundary layers are contract-relevant. # Matches exported funcs, exported interfaces, and exported struct fields with JSON tags. diff_output="" if [[ "$USE_WORKTREE" == "1" ]]; then diff_output="$(git diff -U0 -- "$file")" else diff_output="$(git diff -U0 "$RANGE" -- "$file")" fi if printf '%s\n' "$diff_output" | grep -Eq '^[+-](func (\([^)]*\) )?[A-Z][A-Za-z0-9_]*\(|type [A-Z][A-Za-z0-9_]* interface|[[:space:]]+[A-Z][A-Za-z0-9_]*[[:space:]].*`json:"[^"]+"`)'; then violations+=("$file") fi done <<< "$boundary_files" if [[ "${#violations[@]}" -eq 0 ]]; then log "No contract-relevant signature drift detected; check passed." exit 0 fi err "Contract drift detected: contract-relevant files changed without contracts/*.md updates." err "Update the applicable contract file(s) in contracts/ in the same change." err "Impacted files:" for file in "${violations[@]}"; do err " - $file" done exit 1