Files
boshu2__agentops/scripts/check-contract-compatibility.sh
T
Boden Fuller ec188aca25 fix(docs): rename docs/INDEX.md -> docs/documentation-index.md to resolve case collision with docs/index.md
docs/INDEX.md (contract catalog) and docs/index.md (MkDocs landing) shared the
same case-folded name on case-insensitive filesystems, leaving the working tree
in a phantom-modified state and breaking contract-compatibility-gate when the
script read whichever file the OS resolved at run time.

Resolves TASK-1 + TASK-2 of .agents/plans/2026-04-26-resolve-docs-index-case-collision.md:
- Move catalog content from HEAD:docs/INDEX.md into docs/documentation-index.md
- Repoint contract validators (scripts/check-contract-compatibility.sh,
  scripts/contract-orphans-allowlist.txt) and their tests at the new path
- Update navigation references across README.md, AGENTS.md, CLAUDE.md, and the
  docs/ tree (newcomer-guide, CONTRIBUTING, CI-CD, plans/, positioning/,
  templates/, contract examples)
- Replace stale docs/README.md mentions in CLAUDE.md / AGENTS.md / repo-execution
  profile example with docs/index.md (the actual MkDocs landing)

Verification:
- bash scripts/check-contract-compatibility.sh -> 0 failures, 24 contracts catalogued
- bash tests/scripts/test-contract-compatibility-gate.sh -> 7/7 PASS (verified pre-rebase)
- bash tests/skills/test-repo-native-orchestration.sh -> 28/28 PASS (verified pre-rebase)
- git ls-files | awk casefold-dup scan -> empty

TASK-3 (casefold-collision guard script) and TASK-4 (worktree disposition) from
the plan remain to be done in a follow-up.
2026-04-26 19:32:13 -04:00

457 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# check-contract-compatibility.sh
# Dynamic contract-compatibility gate.
#
# Validates:
# 1. All contract files referenced in docs/documentation-index.md exist on disk
# 2. All contract .md files' embedded schema/file references resolve
# 3. All *.schema.json files are valid JSON
# 4. Orphan allowlist is well-formed and governed
# 5. All contracts on disk are catalogued in docs/documentation-index.md (orphan check)
# 6. Example JSON files conform to their corresponding schemas
# Graceful skip when required tools are missing
for tool in jq python3; do
if ! command -v "$tool" &>/dev/null; then
echo "WARN: $tool not found — skipping contract compatibility check"
exit 0
fi
done
ROOT="${1:-.}"
if [[ ! -d "$ROOT" ]]; then
echo "FAIL: repository root not found: $ROOT"
exit 1
fi
ROOT="$(cd "$ROOT" && pwd)"
CONTRACTS_DIR="$ROOT/docs/contracts"
INDEX="$ROOT/docs/documentation-index.md"
BRIDGE="$ROOT/docs/ol-bridge-contracts.md"
ORPHAN_ALLOWLIST="$ROOT/scripts/contract-orphans-allowlist.txt"
failures=0
warnings=0
INDEX_CONTRACTS_TMP="$(mktemp)"
ALLOWLIST_ENTRIES_TMP="$(mktemp)"
ALLOWLIST_PATHS_TMP="$(mktemp)"
fail() { echo "FAIL: $1"; failures=$((failures + 1)); }
warn() { echo "WARN: $1"; warnings=$((warnings + 1)); }
pass() { echo " OK: $1"; }
trim() {
printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
cleanup() {
rm -f "$INDEX_CONTRACTS_TMP" "$ALLOWLIST_ENTRIES_TMP" "$ALLOWLIST_PATHS_TMP"
}
trap cleanup EXIT
load_orphan_allowlist() {
local line_number=0
local today
today="$(date +%F)"
if [[ ! -f "$ORPHAN_ALLOWLIST" ]]; then
fail "orphan allowlist not found: scripts/contract-orphans-allowlist.txt"
return
fi
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line_number=$((line_number + 1))
local stripped
stripped="$(trim "$raw_line")"
[[ -z "$stripped" ]] && continue
[[ "$stripped" == \#* ]] && continue
local path reason owner expires extra
IFS='|' read -r path reason owner expires extra <<<"$stripped"
path="$(trim "$path")"
reason="$(trim "${reason:-}")"
owner="$(trim "${owner:-}")"
expires="$(trim "${expires:-}")"
extra="$(trim "${extra:-}")"
if [[ -n "$extra" || -z "$path" || -z "$reason" || -z "$owner" || -z "$expires" ]]; then
fail "allowlist line $line_number malformed (expected: path | reason | owner | expires)"
continue
fi
if [[ "$path" == *"*"* || "$path" == *"?"* || "$path" == *"["* || "$path" == *"]"* ]]; then
fail "allowlist line $line_number path contains wildcard: $path"
continue
fi
if [[ "$path" != docs/contracts/* ]] || [[ "$path" == "docs/contracts/" ]]; then
fail "allowlist line $line_number path must be repo-relative under docs/contracts/: $path"
continue
fi
if [[ "$path" == */../* ]] || [[ "$path" == ../* ]] || [[ "$path" == */.. ]]; then
fail "allowlist line $line_number path traversal is not allowed: $path"
continue
fi
if [[ ! "$expires" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
fail "allowlist line $line_number expires must be YYYY-MM-DD: $expires"
continue
fi
if [[ "$expires" < "$today" ]]; then
fail "allowlist line $line_number entry expired on $expires: $path"
continue
fi
if grep -Fxq "$path" "$ALLOWLIST_PATHS_TMP"; then
fail "allowlist line $line_number duplicate path: $path"
continue
fi
printf '%s|%s|%s|%s\n' "$path" "$reason" "$owner" "$expires" >>"$ALLOWLIST_ENTRIES_TMP"
printf '%s\n' "$path" >>"$ALLOWLIST_PATHS_TMP"
done < "$ORPHAN_ALLOWLIST"
if [[ -s "$ALLOWLIST_ENTRIES_TMP" ]]; then
local count
count="$(wc -l < "$ALLOWLIST_ENTRIES_TMP" | tr -d ' ')"
pass "Loaded $count orphan allowlist entrie(s)"
else
pass "No orphan allowlist entries configured"
fi
}
echo "=== Contract compatibility gate ==="
echo ""
# ── Check 1: docs/contracts/ directory exists ──
if [[ ! -d "$CONTRACTS_DIR" ]]; then
echo "WARN: docs/contracts/ directory not found — skipping contract compatibility check"
exit 0
fi
# ── Check 2: Orphan allowlist format and policy ──
echo "--- Orphan allowlist validation ---"
load_orphan_allowlist
echo ""
# ── Check 3: documentation-index.md references resolve ──
echo "--- documentation-index.md link resolution ---"
if [[ -f "$INDEX" ]]; then
# Extract markdown links pointing into contracts/ (outside code blocks)
while IFS= read -r ref; do
[[ -z "$ref" ]] && continue
printf 'docs/%s\n' "$ref" >>"$INDEX_CONTRACTS_TMP"
if [[ -f "$ROOT/docs/$ref" ]]; then
pass "$ref"
else
fail "documentation-index.md references $ref but file not found"
fi
done < <(awk '/^```/{skip=!skip; next} !skip{print}' "$INDEX" \
| grep -oE '\]\(contracts/[A-Za-z0-9_./-]+\)' \
| sed 's/\](//; s/)//' | sort -u)
else
fail "docs/documentation-index.md not found"
fi
sort -u "$INDEX_CONTRACTS_TMP" -o "$INDEX_CONTRACTS_TMP"
echo ""
# ── Check 4: Bridge doc references resolve ──
echo "--- Bridge doc reference resolution ---"
if [[ -f "$BRIDGE" ]]; then
while IFS= read -r ref; do
[[ -z "$ref" ]] && continue
if [[ -f "$ROOT/$ref" ]]; then
pass "$ref"
else
fail "ol-bridge-contracts.md references $ref but file not found"
fi
done < <(awk '/^```/{skip=!skip; next} !skip{print}' "$BRIDGE" \
| grep -oE 'docs/contracts/[A-Za-z0-9_./-]+' | sort -u)
else
warn "docs/ol-bridge-contracts.md not found (optional)"
fi
echo ""
# ── Check 5: Contract .md files' embedded references resolve ──
echo "--- Contract .md cross-references ---"
for md in "$CONTRACTS_DIR"/*.md; do
[[ -f "$md" ]] || continue
basename="$(basename "$md")"
while IFS= read -r ref; do
[[ -z "$ref" ]] && continue
# Try resolving relative to contracts dir, then docs/, repo root, then schemas/
if [[ -f "$CONTRACTS_DIR/$ref" ]] || [[ -f "$ROOT/docs/$ref" ]] || [[ -f "$ROOT/$ref" ]] || [[ -f "$ROOT/schemas/$ref" ]]; then
pass "$basename -> $ref"
else
fail "$basename references $ref but file not found"
fi
done < <(awk '/^```/{skip=!skip; next} !skip{print}' "$md" \
| grep -oE '[A-Za-z0-9_.-]+\.schema\.json' | sort -u)
done
echo ""
# ── Check 6: All *.schema.json files are valid JSON ──
echo "--- Schema JSON validation ---"
for schema in "$CONTRACTS_DIR"/*.schema.json; do
[[ -f "$schema" ]] || continue
basename="$(basename "$schema")"
if jq empty "$schema" 2>/dev/null; then
pass "$basename is valid JSON"
else
fail "$basename is not valid JSON"
fi
done
# Also check schemas/ dir at repo root
if [[ -d "$ROOT/schemas" ]]; then
for schema in "$ROOT/schemas"/*.schema.json; do
[[ -f "$schema" ]] || continue
basename="$(basename "$schema")"
if jq empty "$schema" 2>/dev/null; then
pass "schemas/$basename is valid JSON"
else
fail "schemas/$basename is not valid JSON"
fi
done
fi
echo ""
# ── Check 7: All *.json example files are valid JSON ──
echo "--- Example JSON validation ---"
for example in "$CONTRACTS_DIR"/*.example.json; do
[[ -f "$example" ]] || continue
basename="$(basename "$example")"
if jq empty "$example" 2>/dev/null; then
pass "$basename is valid JSON"
else
fail "$basename is not valid JSON"
fi
done
echo ""
# ── Check 8: Example JSON files conform to their schemas ──
echo "--- Example-to-schema conformance ---"
for example in "$CONTRACTS_DIR"/*.example.json; do
[[ -f "$example" ]] || continue
base="$(basename "$example")"
# Derive schema name: strip .example.json, find matching .schema.json
# memrl-policy.profile.example.json -> memrl-policy.schema.json
# Strategy: try progressively shorter prefixes
schema_found=""
prefix="${base%.example.json}" # e.g. "memrl-policy.profile"
while [[ "$prefix" == *.* ]]; do
candidate="$CONTRACTS_DIR/${prefix%.*}.schema.json"
if [[ -f "$candidate" ]]; then
schema_found="$candidate"
break
fi
prefix="${prefix%.*}"
done
# Also try direct: prefix.schema.json (no dots left to strip)
if [[ -z "$schema_found" ]] && [[ -f "$CONTRACTS_DIR/${prefix}.schema.json" ]]; then
schema_found="$CONTRACTS_DIR/${prefix}.schema.json"
fi
if [[ -z "$schema_found" ]]; then
warn "no schema found for example: $base"
continue
fi
schema_base="$(basename "$schema_found")"
# Validate using embedded Python validator (same pattern as validate-manifests.sh)
if ! output="$(
python3 - "$schema_found" "$example" <<'PYVALIDATE'
import json
import sys
schema_path, data_path = sys.argv[1:3]
with open(schema_path, "r", encoding="utf-8") as handle:
root_schema = json.load(handle)
with open(data_path, "r", encoding="utf-8") as handle:
document = json.load(handle)
errors = []
def json_type_name(value):
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, float):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
return type(value).__name__
def matches_type(expected, value):
if expected == "null":
return value is None
if expected == "boolean":
return isinstance(value, bool)
if expected == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected == "number":
return (isinstance(value, int) or isinstance(value, float)) and not isinstance(value, bool)
if expected == "string":
return isinstance(value, str)
if expected == "array":
return isinstance(value, list)
if expected == "object":
return isinstance(value, dict)
return True
def resolve_ref(ref):
if not ref.startswith("#/"):
raise ValueError(f"unsupported $ref: {ref}")
node = root_schema
for part in ref[2:].split("/"):
part = part.replace("~1", "/").replace("~0", "~")
if isinstance(node, dict) and part in node:
node = node[part]
else:
raise ValueError(f"unresolvable $ref: {ref}")
return node
def validate(schema, value, path):
if "$ref" in schema:
try:
target = resolve_ref(schema["$ref"])
except ValueError as error:
errors.append(f"{path}: {error}")
return
validate(target, value, path)
return
expected_type = schema.get("type")
if expected_type is not None:
if isinstance(expected_type, list):
if not any(matches_type(item, value) for item in expected_type):
errors.append(f"{path}: expected one of {expected_type}, got {json_type_name(value)}")
return
elif not matches_type(expected_type, value):
errors.append(f"{path}: expected {expected_type}, got {json_type_name(value)}")
return
if "const" in schema and value != schema["const"]:
errors.append(f"{path}: expected const {schema['const']!r}, got {value!r}")
if "enum" in schema and value not in schema["enum"]:
errors.append(f"{path}: value {value!r} not in enum {schema['enum']!r}")
if isinstance(value, str) and "minLength" in schema and len(value) < schema["minLength"]:
errors.append(f"{path}: string shorter than minLength {schema['minLength']}")
if isinstance(value, list):
if "minItems" in schema and len(value) < schema["minItems"]:
errors.append(f"{path}: expected at least {schema['minItems']} items")
if "items" in schema:
item_schema = schema["items"]
for index, item in enumerate(value):
validate(item_schema, item, f"{path}[{index}]")
if isinstance(value, dict):
required = schema.get("required", [])
for key in required:
if key not in value:
errors.append(f"{path}: missing required property '{key}'")
properties = schema.get("properties", {})
additional = schema.get("additionalProperties", True)
for key, item in value.items():
item_path = f"{path}.{key}" if path != "$" else f"$.{key}"
if key in properties:
validate(properties[key], item, item_path)
elif additional is False:
errors.append(f"{path}: additional property '{key}' not allowed")
elif isinstance(additional, dict):
validate(additional, item, item_path)
validate(root_schema, document, "$")
if errors:
for line in errors:
print(line)
sys.exit(1)
PYVALIDATE
)"; then
fail "$base failed schema validation against $schema_base"
if [[ -n "$output" ]]; then
while IFS= read -r line; do
echo " $line"
done <<<"$output"
fi
else
pass "$base conforms to $schema_base"
fi
done
echo ""
# ── Check 9: Orphan detection — files on disk not in documentation-index.md ──
echo "--- Orphan detection ---"
if [[ -f "$INDEX" ]]; then
for contract in "$CONTRACTS_DIR"/*; do
[[ -f "$contract" ]] || continue
rel_contract="${contract#"$ROOT"/}"
if grep -Fxq "$rel_contract" "$INDEX_CONTRACTS_TMP" 2>/dev/null; then
pass "$rel_contract catalogued in documentation-index.md"
elif grep -Fxq "$rel_contract" "$ALLOWLIST_PATHS_TMP" 2>/dev/null; then
metadata="$(grep -F "^$rel_contract|" "$ALLOWLIST_ENTRIES_TMP" | head -1 || true)"
reason="$(trim "$(printf '%s' "$metadata" | cut -d'|' -f2)")"
owner="$(trim "$(printf '%s' "$metadata" | cut -d'|' -f3)")"
expires="$(trim "$(printf '%s' "$metadata" | cut -d'|' -f4)")"
pass "$rel_contract allowlisted ($reason; $owner; expires $expires)"
else
fail "$rel_contract exists on disk but not in documentation-index.md (not allowlisted)"
fi
done
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
allowlisted_path="$(printf '%s' "$entry" | cut -d'|' -f1)"
if [[ ! -f "$ROOT/$allowlisted_path" ]]; then
fail "allowlist entry points to missing file: $allowlisted_path"
continue
fi
if grep -Fxq "$allowlisted_path" "$INDEX_CONTRACTS_TMP" 2>/dev/null; then
fail "allowlist entry is stale (already catalogued in documentation-index.md): $allowlisted_path"
fi
done < "$ALLOWLIST_ENTRIES_TMP"
fi
echo ""
# ── Summary ──
echo "=== Summary ==="
echo "Failures: $failures"
echo "Warnings: $warnings"
if [[ "$failures" -gt 0 ]]; then
echo ""
echo "Contract compatibility check failed."
exit 1
fi
echo ""
echo "Contract compatibility check passed."