diff --git a/architecture/CONTRACT-TEMPLATE.md b/architecture/CONTRACT-TEMPLATE.md index a37674a..fa7fc59 100644 --- a/architecture/CONTRACT-TEMPLATE.md +++ b/architecture/CONTRACT-TEMPLATE.md @@ -1,13 +1,25 @@ -# CONTRACT-{NAME}.{MAJOR}.{MINOR} +# CONTRACT-{NAMESPACE}:{NAME}.{MAJOR}.{MINOR} + Remove these HTML comments when done. + + Filename: CONTRACT-{NAME}.{MAJOR}.{MINOR}.md ← filenames stay unnamespaced + Title: # CONTRACT-{NAMESPACE}:{NAME}.{MAJOR}.{MINOR} + In-source: CONTRACT:{NAMESPACE}:{NAME}.{MAJOR}.{MINOR} + + {NAMESPACE} = your repo's namespace from .rebarrc contract_namespace + (e.g. github.com/myorg/myrepo) + {NAME} = contract ID (e.g. AUTH, BLOBSTORE, KEY-EXCHANGE) + + Legacy form CONTRACT:{NAME}.{v} is still valid — both are recognised + by rebar's enforcement and steward scan. Use namespaced form when this + contract may be referenced from other repos. --> **Version:** {MAJOR}.{MINOR} @@ -112,7 +124,7 @@ type BlobStore interface { -- Depends on: `CONTRACT:I2-KEY-EXCHANGE.1.0` for encryption keys +- Depends on: `CONTRACT:{NAMESPACE}:KEY-EXCHANGE.1.0` for encryption keys - Configuration: `BLOBSTORE_PATH` environment variable - External: none (self-contained) @@ -142,15 +154,18 @@ type BlobStore interface { OPTIONAL otherwise. Without a deadline, superseded contracts accumulate indefinite-state lag (see filedag's C9-ABAC retirement-lag finding). --> -- **Predecessor:** `CONTRACT-.` — retirement criterion: `grep -rn ""` returns zero +- **Predecessor:** `CONTRACT-.` — retirement criterion: `grep -rEn "CONTRACT:([^:]+:)?\."` returns zero - **Migration deadline:** YYYY-MM-DD or named phase boundary - **Migration owner:** [team or person responsible for the cutover] ## Implementing Files - `internal/blobstore/file.go` — file-backed implementation @@ -203,8 +218,8 @@ type BlobStore interface { the companion without bumping the contract version. The companion lives alongside the contract in architecture/: - architecture/CONTRACT-C1-BLOBSTORE.2.1.md ← the contract - architecture/CONTRACT-C1-BLOBSTORE.impl.md ← the companion + architecture/CONTRACT-BLOBSTORE.2.1.md ← the contract (filename unnamespaced) + architecture/CONTRACT-BLOBSTORE.impl.md ← the companion --> ## Change History diff --git a/cli/cmd/check.go b/cli/cmd/check.go index 6dd751f..84c1c50 100644 --- a/cli/cmd/check.go +++ b/cli/cmd/check.go @@ -3,37 +3,89 @@ package cmd import ( "fmt" "os" + "path/filepath" "github.com/spf13/cobra" "github.com/willackerly/rebar/cli/internal/scripts" ) var checkStrict bool +var checkPreCommit bool var checkCmd = &cobra.Command{ Use: "check", - Short: "Run all enforcement checks", - Long: `Runs steward and CI checks via scripts/ci-check.sh.`, - RunE: runCheck, + Short: "Run enforcement checks", + Long: `Run enforcement checks against the current project. + +Without flags: runs the full check suite via scripts/ci-check.sh. +--pre-commit: runs fast pre-commit checks (TODOs, contract refs) directly + from the rebar installation — no project scripts involved. + Called by the .git/hooks/pre-commit hook.`, + RunE: runCheck, } func init() { - checkCmd.Flags().BoolVar(&checkStrict, "strict", true, "exit 1 on any failure") + checkCmd.Flags().BoolVar(&checkStrict, "strict", true, "exit 1 on any failure (full check only)") + checkCmd.Flags().BoolVar(&checkPreCommit, "pre-commit", false, "run fast pre-commit checks from rebar install") } func runCheck(cmd *cobra.Command, args []string) error { + if checkPreCommit { + return runPreCommitChecks() + } + scriptArgs := []string{} if checkStrict { scriptArgs = append(scriptArgs, "--strict") } - exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, "ci-check.sh", scriptArgs...) if err != nil { return fmt.Errorf("running checks: %w", err) } - if exitCode != 0 { os.Exit(exitCode) } return nil } + +// runPreCommitChecks runs the fast enforcement subset from the rebar +// installation's scripts/ directory — not from the project's scripts/. +// This avoids the circular chain: +// pre-commit.sh → rebar check --pre-commit → rebar home scripts +// The project's scripts/pre-commit.sh is a 3-line entry point only. +func runPreCommitChecks() error { + rebarRoot := findRebarRoot() + if rebarRoot == "" { + fmt.Fprintln(os.Stderr, "pre-commit: rebar installation not found — skipping checks") + fmt.Fprintln(os.Stderr, " Set REBAR_ROOT or ensure rebar is installed at ~/.rebar") + return nil // fail-open: don't block commits on missing rebar + } + + rebarScripts := filepath.Join(rebarRoot, "scripts") + + // Fast checks appropriate for pre-commit (<5s total). + // These run from the rebar install, not the project's scripts/. + fastChecks := []string{ + "check-todos.sh", + "check-contract-refs.sh", + } + + failed := 0 + for _, name := range fastChecks { + if _, err := os.Stat(filepath.Join(rebarScripts, name)); err != nil { + continue // script not present in this rebar version — skip + } + fmt.Printf(" checking: %s\n", name) + exitCode, err := scripts.RunPassthrough(rebarScripts, name) + if err != nil || exitCode != 0 { + failed++ + } + } + + if failed > 0 { + fmt.Fprintf(os.Stderr, "\n%d pre-commit check(s) failed. Fix above or skip with --no-verify.\n", failed) + os.Exit(1) + } + fmt.Println(" pre-commit checks passed.") + return nil +} diff --git a/cli/cmd/init.go b/cli/cmd/init.go index cbbd9b8..cad841e 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -278,7 +278,7 @@ _None currently._ } } if copiedScripts > 0 { - fmt.Printf(" Created scripts/ (%d scripts incl. cold-start-checks.sh, ci-check.sh, inbox-watch.sh)\n", copiedScripts) + fmt.Printf(" Created scripts/ (%d scripts: pre-commit.sh, ci-check.sh — thin wrappers calling rebar)\n", copiedScripts) created++ } } @@ -493,7 +493,9 @@ func findRebarRoot() string { if dir == "" { return false } - _, err := os.Stat(filepath.Join(dir, "templates", "project-bootstrap", "scripts", "steward.sh")) + // setup-rebar.sh exists in both installed framework dirs and source checkouts; + // steward.sh was removed from project-bootstrap/scripts/ in the thin-scripts refactor. + _, err := os.Stat(filepath.Join(dir, "setup-rebar.sh")) return err == nil } diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 279a529..a352889 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -10,13 +10,14 @@ import ( ) type Config struct { - RepoRoot string - RebarDir string // .rebar/ - Tier int // 1, 2, or 3 - Version string // from .rebar-version - ScriptsDir string // scripts/ - AgentsDir string // agents/ - BinDir string // bin/ + RepoRoot string + RebarDir string // .rebar/ + Tier int // 1, 2, or 3 + Version string // from .rebar-version + ScriptsDir string // scripts/ + AgentsDir string // agents/ + BinDir string // bin/ + ContractNamespace string // contract_namespace from .rebarrc (e.g. github.com/org/repo) } // Load reads configuration from .rebarrc and .rebar-version, respecting @@ -39,9 +40,12 @@ func Load(repoRoot string) (*Config, error) { } } else { // Read from .rebarrc - tier, err := readRebarRC(filepath.Join(repoRoot, ".rebarrc")) - if err == nil && tier >= 1 && tier <= 3 { - c.Tier = tier + rc, err := readRebarRC(filepath.Join(repoRoot, ".rebarrc")) + if err == nil { + if rc.tier >= 1 && rc.tier <= 3 { + c.Tier = rc.tier + } + c.ContractNamespace = rc.namespace } } @@ -81,15 +85,20 @@ func FindRepoRoot(dir string) (string, error) { } } -// readRebarRC parses a .rebarrc file for the tier setting. -// Format: key = value lines, comments with #. -func readRebarRC(path string) (int, error) { +type rebarRC struct { + tier int + namespace string +} + +// readRebarRC parses a .rebarrc file. Format: key = value lines, # comments. +func readRebarRC(path string) (rebarRC, error) { f, err := os.Open(path) if err != nil { - return 0, err + return rebarRC{}, err } defer f.Close() + var rc rebarRC scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -102,11 +111,17 @@ func readRebarRC(path string) (int, error) { } key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) - if strings.EqualFold(key, "tier") || strings.EqualFold(key, "rebar_tier") { - return strconv.Atoi(val) + switch { + case strings.EqualFold(key, "tier") || strings.EqualFold(key, "rebar_tier"): + rc.tier, _ = strconv.Atoi(val) + case strings.EqualFold(key, "contract_namespace"): + rc.namespace = val } } - return 0, fmt.Errorf("tier not found in .rebarrc") + if rc.tier == 0 { + return rc, fmt.Errorf("tier not found in .rebarrc") + } + return rc, nil } // EnsureRebarDir creates the .rebar/ directory structure. diff --git a/scripts/steward.sh b/scripts/steward.sh index eef3b50..388ed67 100755 --- a/scripts/steward.sh +++ b/scripts/steward.sh @@ -85,15 +85,19 @@ scan_contract() { completeness="fail" fi - # Implementing files: grep for CONTRACT: across the project + # Implementing files: grep for CONTRACT: across the project. + # Matches both legacy (CONTRACT:.) and namespaced + # (CONTRACT::.) references so repos in transition + # are scanned correctly regardless of which form they use. local impl_files=() local test_files=() + local impl_pattern="CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?${id}\\." while IFS= read -r line; do local filepath filepath="$(echo "$line" | cut -d: -f1)" impl_files+=("$filepath") - done < <(grep -rn "CONTRACT:${id}" "$PROJECT_ROOT" \ + done < <(grep -rEn "$impl_pattern" "$PROJECT_ROOT" \ --include='*.go' --include='*.ts' --include='*.js' --include='*.py' \ --include='*.rs' --include='*.java' --include='*.rb' --include='*.c' \ --include='*.cpp' --include='*.h' --include='*.cs' --include='*.swift' \ diff --git a/setup-rebar.sh b/setup-rebar.sh index 3221325..87d8d33 100755 --- a/setup-rebar.sh +++ b/setup-rebar.sh @@ -1,27 +1,30 @@ #!/usr/bin/env bash -# setup-rebar.sh — One-line installer for rebar. +# setup-rebar.sh — Versioned one-line installer for rebar. # -# Clones rebar to $REBAR_DIR (default ~/.rebar), runs the canonical -# `bin/install` to add ASK + rebar to your PATH, and exits pointing -# you at `rebar new` / `rebar adopt` for actual project bootstrap. +# Release tags (vX.Y.Z) — download prebuilt binary from GitHub Releases +# Branches / dev refs — clone repo and build from source (requires Go) # -# This is intentionally a thin shim over rebar's existing tooling -# (`bin/install`, `rebar new`, `rebar adopt`) — not a parallel -# bootstrap. Anything beyond clone + PATH wiring belongs in the -# Go CLI so it stays in sync with the rest of the project. +# Usage — curl pipe (most common): +# curl -fsSL https://raw.githubusercontent.com/willackerly/rebar/main/setup-rebar.sh \ +# | bash -s -- v3.0.0-beta.2 # -# Usage (curl pipe): -# curl -fsSL https://raw.githubusercontent.com/willackerly/rebar/v3.0.0-beta.2/setup-rebar.sh | bash +# Usage — local: +# ./setup-rebar.sh [] [--server HOST:PORT] [--help] # -# Usage (local): -# ./setup-rebar.sh [--server HOST:PORT] [--dir PATH] +# Multiple versions coexist under ~/.rebar/versions//. +# The 'current' symlink points at the active one; bin/rebar resolves it. +# +# Env overrides: +# REBAR_GITHUB owner/repo (default: willackerly/rebar) +# REBAR_BASE install base dir (default: ~/.rebar) +# ASK_SERVER remote ASK server set -euo pipefail -REBAR_REPO="${REBAR_REPO:-https://github.com/willackerly/rebar.git}" -REBAR_REF="${REBAR_REF:-v3.0.0-beta.2}" -REBAR_DIR="${REBAR_DIR:-$HOME/.rebar}" +REBAR_GITHUB="${REBAR_GITHUB:-willackerly/rebar}" +REBAR_BASE="${REBAR_BASE:-$HOME/.rebar}" ASK_SERVER="${ASK_SERVER:-}" +VERSION="" if [[ -t 1 ]]; then RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' @@ -32,90 +35,191 @@ fi log() { printf '%s[rebar]%s %s\n' "$BLUE" "$NC" "$*"; } warn() { printf '%s[rebar]%s %s\n' "$YELLOW" "$NC" "$*" >&2; } -err() { printf '%s[rebar]%s %s\n' "$RED" "$NC" "$*" >&2; } +err() { printf '%s[rebar]%s %s\n' "$RED" "$NC" "$*" >&2; exit 1; } ok() { printf '%s[rebar]%s %s\n' "$GREEN" "$NC" "$*"; } usage() { cat <] [--server HOST:PORT] $0 --help -Env vars (override flags): - REBAR_DIR target install dir (default: ~/.rebar) - REBAR_REPO git remote to clone from (default: github.com/willackerly/rebar) - REBAR_REF branch/tag to check out (default: v3.0.0-beta.2) - ASK_SERVER remote ASK server, written to your shell RC by bin/install + release tag (v3.0.0) or branch name (default: latest release tag) + +Examples: + curl -fsSL .../setup-rebar.sh | bash -s -- v3.0.0-beta.2 + ./setup-rebar.sh main --server localhost:8080 -Next steps after install: - rebar new my-project -d "what it does" # create a new rebar project - rebar adopt # adopt rebar in an existing repo +Env overrides: + REBAR_GITHUB GitHub owner/repo (default: willackerly/rebar) + REBAR_BASE install base dir (default: ~/.rebar) + ASK_SERVER remote ASK server EOF } +# ── Argument parsing ────────────────────────────────────────────────────────── + while [[ $# -gt 0 ]]; do case "$1" in --server) ASK_SERVER="$2"; shift 2 ;; --server=*) ASK_SERVER="${1#--server=}"; shift ;; - --dir) REBAR_DIR="$2"; shift 2 ;; - --dir=*) REBAR_DIR="${1#--dir=}"; shift ;; - --ref) REBAR_REF="$2"; shift 2 ;; - --ref=*) REBAR_REF="${1#--ref=}"; shift ;; -h|--help) usage; exit 0 ;; - *) err "unknown option: $1"; usage >&2; exit 2 ;; + -*) err "Unknown option: $1" ;; + *) VERSION="$1"; shift ;; esac done -if ! command -v git >/dev/null 2>&1; then - err "git is required. Install it and re-run." - exit 1 +# ── Resolve version ─────────────────────────────────────────────────────────── + +is_release_tag() { + [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9] ]] +} + +if [[ -z "$VERSION" ]]; then + log "Resolving latest release tag from GitHub..." + if command -v curl &>/dev/null; then + VERSION=$(curl -fsSL "https://api.github.com/repos/${REBAR_GITHUB}/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + elif command -v wget &>/dev/null; then + VERSION=$(wget -qO- "https://api.github.com/repos/${REBAR_GITHUB}/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + else + err "curl or wget required. Install either and re-run, or pass a version explicitly." + fi + [[ -z "$VERSION" ]] && err "Could not resolve latest release tag. Pass a version explicitly: $0 v3.0.0" + log "Latest: $VERSION" fi -if [[ -d "$REBAR_DIR/.git" ]]; then - log "Updating existing rebar checkout at $REBAR_DIR" - if ! git -C "$REBAR_DIR" diff --quiet || ! git -C "$REBAR_DIR" diff --cached --quiet; then - warn "$REBAR_DIR has uncommitted changes — skipping fetch/checkout" +INSTALL_DIR="${REBAR_BASE}/versions/${VERSION}" +CURRENT_LINK="${REBAR_BASE}/current" + +log "Installing rebar ${VERSION} → ${INSTALL_DIR}" + +mkdir -p "$INSTALL_DIR" + +# ── Download or build ───────────────────────────────────────────────────────── + +detect_platform() { + local os arch + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "$arch" in + x86_64|amd64) arch="x86_64" ;; + arm64|aarch64) arch="arm64" ;; + *) arch="$arch" ;; + esac + echo "${os}_${arch}" +} + +install_from_release() { + local platform + platform="$(detect_platform)" + local asset="rebar_${VERSION#v}_${platform}.tar.gz" + local url="https://github.com/${REBAR_GITHUB}/releases/download/${VERSION}/${asset}" + + log "Downloading prebuilt binary: $asset" + + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + if command -v curl &>/dev/null; then + curl -fsSL --output "${tmp}/${asset}" "$url" \ + || { warn "Prebuilt binary not found for $platform — falling back to source build"; return 1; } else - git -C "$REBAR_DIR" fetch --tags origin "$REBAR_REF" - git -C "$REBAR_DIR" checkout "$REBAR_REF" - git -C "$REBAR_DIR" pull --ff-only origin "$REBAR_REF" || true + wget -qO "${tmp}/${asset}" "$url" \ + || { warn "Prebuilt binary not found for $platform — falling back to source build"; return 1; } fi -elif [[ -e "$REBAR_DIR" ]]; then - err "$REBAR_DIR exists but is not a git checkout." - err "Move it aside or set REBAR_DIR= and re-run." - exit 1 + + tar -xzf "${tmp}/${asset}" -C "$INSTALL_DIR" --strip-components=0 + chmod +x "${INSTALL_DIR}/bin/rebar" 2>/dev/null || true + return 0 +} + +install_from_source() { + local ref="$1" + log "Building from source (ref: $ref)..." + command -v go &>/dev/null || err "Go is required to build from source. Install Go and re-run." + command -v git &>/dev/null || err "git is required. Install it and re-run." + + local src_dir="${INSTALL_DIR}/.src" + local clone_url="${REBAR_REPO:-https://github.com/${REBAR_GITHUB}.git}" + if [[ -d "${src_dir}/.git" ]]; then + log "Updating existing checkout..." + git -C "$src_dir" fetch --tags origin + git -C "$src_dir" checkout "$ref" + git -C "$src_dir" pull --ff-only origin "$ref" 2>/dev/null || true + else + log "Cloning ${clone_url} (ref: $ref)..." + git clone --branch "$ref" --depth=1 "$clone_url" "$src_dir" + fi + + # Copy framework files + cp -r "${src_dir}/." "${INSTALL_DIR}/" + + # Build binary with version injected + log "Building rebar binary..." + mkdir -p "${INSTALL_DIR}/bin" + (cd "$src_dir/cli" && go build \ + -ldflags="-s -w -X github.com/willackerly/rebar/cli/cmd.Version=${VERSION}" \ + -o "${INSTALL_DIR}/bin/rebar" .) + chmod +x "${INSTALL_DIR}/bin/rebar" +} + +if is_release_tag "$VERSION"; then + install_from_release || install_from_source "$VERSION" else - log "Cloning $REBAR_REPO ($REBAR_REF) → $REBAR_DIR" - git clone --branch "$REBAR_REF" --depth=1 "$REBAR_REPO" "$REBAR_DIR" + install_from_source "$VERSION" fi -if [[ ! -x "$REBAR_DIR/bin/install" ]]; then - err "$REBAR_DIR/bin/install not found or not executable." - err "The clone may have failed or the ref does not contain bin/install." - exit 1 +# ── Verify binary ───────────────────────────────────────────────────────────── + +[[ -x "${INSTALL_DIR}/bin/rebar" ]] \ + || err "Installation failed — ${INSTALL_DIR}/bin/rebar not found." + +# ── Update 'current' symlink ────────────────────────────────────────────────── + +mkdir -p "${REBAR_BASE}/bin" +ln -sfn "${INSTALL_DIR}" "${CURRENT_LINK}" + +# ── Wire PATH via bin/install ───────────────────────────────────────────────── + +INSTALL_BIN="${INSTALL_DIR}/bin/install" +if [[ -x "$INSTALL_BIN" ]]; then + install_args=() + [[ -n "$ASK_SERVER" ]] && install_args+=(--server "$ASK_SERVER") + log "Running bin/install..." + "$INSTALL_BIN" "${install_args[@]}" +else + # Fallback: write PATH entry manually + warn "bin/install not found — writing PATH entry manually" + SHELL_RC="" + [[ -f "$HOME/.zshrc" ]] && SHELL_RC="$HOME/.zshrc" + [[ -f "$HOME/.bashrc" ]] && SHELL_RC="${SHELL_RC:-$HOME/.bashrc}" + [[ -f "$HOME/.profile" ]] && SHELL_RC="${SHELL_RC:-$HOME/.profile}" + if [[ -n "$SHELL_RC" ]]; then + if ! grep -q 'rebar/current/bin' "$SHELL_RC" 2>/dev/null; then + printf '\n# rebar\nexport PATH="%s/current/bin:$PATH"\n' "${REBAR_BASE}" >> "$SHELL_RC" + log "Added ${REBAR_BASE}/current/bin to PATH in $SHELL_RC" + fi + fi fi -install_args=() -[[ -n "$ASK_SERVER" ]] && install_args+=(--server "$ASK_SERVER") -log "Running $REBAR_DIR/bin/install ${install_args[*]:-}" -"$REBAR_DIR/bin/install" "${install_args[@]}" +# ── Done ────────────────────────────────────────────────────────────────────── -ok "rebar installed at $REBAR_DIR (ref: $REBAR_REF)" +ok "rebar ${VERSION} installed" cat <.. (legacy / local) +# CONTRACT::.. (namespaced / cross-repo) +# Both forms are recognised by rebar's enforcement and steward scan. +# +# Leave unset for single-repo projects or until you need cross-repo references. +# contract_namespace = github.com/myorg/myrepo # Version compatibility REBAR_VERSION=3.0.0-beta diff --git a/templates/project-bootstrap/architecture/CONTRACT-TEMPLATE.md b/templates/project-bootstrap/architecture/CONTRACT-TEMPLATE.md index a37674a..fa7fc59 100644 --- a/templates/project-bootstrap/architecture/CONTRACT-TEMPLATE.md +++ b/templates/project-bootstrap/architecture/CONTRACT-TEMPLATE.md @@ -1,13 +1,25 @@ -# CONTRACT-{NAME}.{MAJOR}.{MINOR} +# CONTRACT-{NAMESPACE}:{NAME}.{MAJOR}.{MINOR} + Remove these HTML comments when done. + + Filename: CONTRACT-{NAME}.{MAJOR}.{MINOR}.md ← filenames stay unnamespaced + Title: # CONTRACT-{NAMESPACE}:{NAME}.{MAJOR}.{MINOR} + In-source: CONTRACT:{NAMESPACE}:{NAME}.{MAJOR}.{MINOR} + + {NAMESPACE} = your repo's namespace from .rebarrc contract_namespace + (e.g. github.com/myorg/myrepo) + {NAME} = contract ID (e.g. AUTH, BLOBSTORE, KEY-EXCHANGE) + + Legacy form CONTRACT:{NAME}.{v} is still valid — both are recognised + by rebar's enforcement and steward scan. Use namespaced form when this + contract may be referenced from other repos. --> **Version:** {MAJOR}.{MINOR} @@ -112,7 +124,7 @@ type BlobStore interface { -- Depends on: `CONTRACT:I2-KEY-EXCHANGE.1.0` for encryption keys +- Depends on: `CONTRACT:{NAMESPACE}:KEY-EXCHANGE.1.0` for encryption keys - Configuration: `BLOBSTORE_PATH` environment variable - External: none (self-contained) @@ -142,15 +154,18 @@ type BlobStore interface { OPTIONAL otherwise. Without a deadline, superseded contracts accumulate indefinite-state lag (see filedag's C9-ABAC retirement-lag finding). --> -- **Predecessor:** `CONTRACT-.` — retirement criterion: `grep -rn ""` returns zero +- **Predecessor:** `CONTRACT-.` — retirement criterion: `grep -rEn "CONTRACT:([^:]+:)?\."` returns zero - **Migration deadline:** YYYY-MM-DD or named phase boundary - **Migration owner:** [team or person responsible for the cutover] ## Implementing Files - `internal/blobstore/file.go` — file-backed implementation @@ -203,8 +218,8 @@ type BlobStore interface { the companion without bumping the contract version. The companion lives alongside the contract in architecture/: - architecture/CONTRACT-C1-BLOBSTORE.2.1.md ← the contract - architecture/CONTRACT-C1-BLOBSTORE.impl.md ← the companion + architecture/CONTRACT-BLOBSTORE.2.1.md ← the contract (filename unnamespaced) + architecture/CONTRACT-BLOBSTORE.impl.md ← the companion --> ## Change History diff --git a/templates/project-bootstrap/scripts/README.md b/templates/project-bootstrap/scripts/README.md index 137ed83..fa1a9a9 100644 --- a/templates/project-bootstrap/scripts/README.md +++ b/templates/project-bootstrap/scripts/README.md @@ -1,81 +1,49 @@ -# Scripts +# scripts/ -Enforcement scripts and quality scanning for the contract-driven methodology. +Thin wrappers that delegate enforcement to the **rebar CLI**. The logic lives +in the rebar installation — adopting repos carry only these entry points, not +copies of rebar's internals. -See the [root README](../README.md) for how scripts fit into the overall system. +## Files -## Quality Scanner +| File | Purpose | +|------|---------| +| `pre-commit.sh` | Git pre-commit hook → `rebar check --pre-commit` | +| `ci-check.sh` | CI entrypoint → `rebar audit` | -| Script | What It Does | Invocation | -|--------|-------------|-----------| -| `steward.sh` | Full quality scan — contract lifecycle, enforcement, discoveries | `ask steward` or `./scripts/steward.sh` | -| `steward.sh --json` | Aggregate JSON to stdout | `ask steward json` | -| `steward.sh --summary` | One-line health summary | `ask steward summary` | -| `steward.sh --check ` | Single contract scan | `ask steward check C1` | +## Wire the pre-commit hook -Output goes to `architecture/.state/` (JSON) and `STEWARD_REPORT.md` (human-readable). - -## Enforcement Checks - -Each script is standalone, runs in <5 seconds, and exits 0 (pass) or 1 (fail). +```bash +ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit +``` -| Script | What It Checks | -|--------|---------------| -| `check-contract-headers.sh` | Every source file has a `CONTRACT:` header | -| `check-contract-refs.sh` | Every `CONTRACT:` ref points to a real contract file | -| `check-todos.sh` | No untracked `TODO:` comments (two-tag system) | -| `check-freshness.sh` | Doc freshness dates aren't stale (>14 days) | -| `check-registry.sh` | Contract registry matches actual files | -| `check-ground-truth.sh` | METRICS file matches codebase reality | +Or use `rebar init` — it wires the hook automatically. -## Composite Runners +## How it works -| Script | When to Run | -|--------|-------------| -| `ci-check.sh` | CI pipeline — runs all checks including steward | -| `pre-commit.sh` | Git hook — fast checks (TODOs + contract refs) | +`pre-commit.sh` and `ci-check.sh` call the `rebar` binary in `$PATH`. +All enforcement logic (contract reference checks, TODO tracking, freshness, +registry consistency, steward scan, etc.) lives inside the rebar binary — +sourced from the rebar installation, not from this directory. -## Installation +This means: +- Upgrading rebar upgrades enforcement automatically — no scripts to sync. +- Adopting repos stay clean: only entry points here, no duplicated logic. +- CI and local pre-commit use the same `rebar` command, same logic, same output. -```bash -# Pre-commit hook (pick one) -cp scripts/pre-commit.sh .git/hooks/pre-commit # copy -ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit # symlink +## Extending -# Make all scripts executable -chmod +x scripts/*.sh +Add project-specific checks by creating additional scripts alongside these +that call `rebar check ` or `rebar audit --only `. Project-specific +logic that cannot live in rebar belongs in a separate tool, not in copies of +rebar's internals. -# CI pipeline (GitHub Actions example) -# - run: ./scripts/ci-check.sh --strict -``` +## Suppress individual checks in CI -## Configuration +`rebar audit` respects `SKIP_*` env vars: -| Environment Variable | Default | Purpose | -|---------------------|---------|---------| -| `CONTRACT_EXTENSIONS` | `.go .ts .tsx .js .jsx .py .rs` | File extensions to scan | -| `SKIP_CONTRACT_HEADERS` | `0` | Skip header check | -| `SKIP_CONTRACT_REFS` | `0` | Skip ref check | -| `SKIP_TODOS` | `0` | Skip TODO check | -| `SKIP_FRESHNESS` | `0` | Skip freshness check | -| `SKIP_REGISTRY` | `0` | Skip registry check | -| `SKIP_GROUND_TRUTH` | `0` | Skip ground truth check | -| `SKIP_STEWARD` | `0` | Skip steward scan | - -## Automation Hierarchy - -``` -Pre-commit (fast, <5s) CI (thorough, <30s) Full scan (comprehensive) -├── check-todos.sh ├── all pre-commit checks ├── all CI checks -└── check-contract-refs.sh ├── check-contract-headers ├── steward.sh (lifecycle, - ├── check-freshness discoveries, action items) - ├── check-registry └── STEWARD_REPORT.md - ├── check-ground-truth - └── steward.sh +```bash +SKIP_FRESHNESS=1 ./scripts/ci-check.sh ``` -## Dependencies - -- **bash** — all scripts are bash -- **jq** — required by steward.sh and ground truth verification -- **grep, find** — standard Unix tools +See `rebar audit --help` for the full list. diff --git a/templates/project-bootstrap/scripts/_rebar-config.sh b/templates/project-bootstrap/scripts/_rebar-config.sh deleted file mode 100644 index 6a8fee0..0000000 --- a/templates/project-bootstrap/scripts/_rebar-config.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# _rebar-config.sh — Shared configuration for rebar enforcement scripts -# rebar-scripts: 2026.03.20 -# -# Source this from any enforcement script: -# source "$(dirname "$0")/_rebar-config.sh" -# -# Provides: -# _rebar_tier — returns the configured enforcement tier (1, 2, or 3) -# _rebar_skip — returns 0 (should run) or 1 (should skip) for a minimum tier -# -# Tier definitions: -# 1 = Partial — contract-refs + TODOs only (minimum viable) -# 2 = Adopted — + contract-headers, freshness, registry -# 3 = Enforced — + ground-truth, strict steward (full enforcement) -# -# Configuration priority: REBAR_TIER env var > .rebarrc file > default (3) - -_rebar_tier() { - # 1. Environment variable (highest priority) - if [ -n "${REBAR_TIER:-}" ]; then - echo "$REBAR_TIER" - return - fi - - # 2. .rebarrc file (project-level config) - local script_dir - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - local project_root - project_root="$(cd "$script_dir/.." && pwd)" - - local rc_file="$project_root/.rebarrc" - if [ -f "$rc_file" ]; then - local tier - tier=$(grep '^tier' "$rc_file" 2>/dev/null | head -1 | sed 's/.*=[[:space:]]*//' | tr -d ' ') - if [ -n "$tier" ] && [[ "$tier" =~ ^[123]$ ]]; then - echo "$tier" - return - fi - fi - - # 3. Default: full enforcement - echo "3" -} - -# Check if a script should run based on its minimum required tier -# Usage: _rebar_skip 2 && exit 0 # skip if tier < 2 -_rebar_skip() { - local min_tier="$1" - local current_tier - current_tier=$(_rebar_tier) - - if [ "$current_tier" -lt "$min_tier" ]; then - echo "SKIP: tier $current_tier < required tier $min_tier (set REBAR_TIER or .rebarrc to change)" - return 0 # true = should skip - fi - return 1 # false = should run -} - -# Read the rebar-scripts version from a script file -_rebar_script_version() { - local file="$1" - grep '^# rebar-scripts:' "$file" 2>/dev/null | head -1 | sed 's/^# rebar-scripts:[[:space:]]*//' -} diff --git a/templates/project-bootstrap/scripts/agent-health.sh b/templates/project-bootstrap/scripts/agent-health.sh deleted file mode 100755 index f696ca8..0000000 --- a/templates/project-bootstrap/scripts/agent-health.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# agent-health.sh — Source-able health primitives for worktree agents -# rebar-scripts: 2026.03.21 -# -# Usage: source this file in agent prompts or test-stack scripts. -# -# source scripts/agent-health.sh -# agent_checkpoint "fix: correct font ascender metric" -# agent_heartbeat -# agent_metric "rmse_delta" "-0.045" -# -# All output goes to /tmp/agent-.* files. The parent can poll these -# or the shared progress file to monitor the swarm. - -set -euo pipefail - -# Agent identity — derived from worktree name or PID -AGENT_ID="${AGENT_ID:-$(basename "$(git rev-parse --show-toplevel 2>/dev/null || echo "agent-$$")")}" -PROGRESS_FILE="${AGENT_PROGRESS_FILE:-agent-progress.jsonl}" - -_agent_ts() { - date -u +%Y-%m-%dT%H:%M:%SZ -} - -# --- Checkpoint: stage tracked files and commit --- -# Commits only tracked files (git add -u), never untracked. This prevents -# agents from accidentally committing generated artifacts. -agent_checkpoint() { - local msg="${1:?Usage: agent_checkpoint \"commit message\"}" - git add -u - if git diff --cached --quiet; then - echo "[agent-health] nothing to commit" - return 0 - fi - git commit -m "$msg" - local hash - hash=$(git rev-parse --short HEAD) - echo "[agent-health] checkpoint: $hash $msg" - - # Append to shared progress file if it exists - if [ -n "$PROGRESS_FILE" ]; then - local entry - entry=$(printf '{"agent":"%s","ts":"%s","type":"checkpoint","commit":"%s","message":"%s"}\n' \ - "$AGENT_ID" "$(_agent_ts)" "$hash" "$msg") - echo "$entry" >> "$PROGRESS_FILE" 2>/dev/null || true - fi -} - -# --- Heartbeat: signal that agent is alive --- -agent_heartbeat() { - local hb_file="/tmp/agent-${AGENT_ID}.heartbeat" - _agent_ts > "$hb_file" -} - -# --- Metric: record a key-value measurement --- -agent_metric() { - local key="${1:?Usage: agent_metric \"key\" \"value\"}" - local value="${2:?Usage: agent_metric \"key\" \"value\"}" - local metrics_file="/tmp/agent-${AGENT_ID}.metrics.jsonl" - printf '{"agent":"%s","ts":"%s","key":"%s","value":%s}\n' \ - "$AGENT_ID" "$(_agent_ts)" "$key" "$value" >> "$metrics_file" - - # Also append to shared progress - if [ -n "$PROGRESS_FILE" ]; then - printf '{"agent":"%s","ts":"%s","type":"metric","key":"%s","value":%s}\n' \ - "$AGENT_ID" "$(_agent_ts)" "$key" "$value" >> "$PROGRESS_FILE" 2>/dev/null || true - fi -} - -# --- RMSE delta: specialized metric for fidelity work --- -agent_rmse() { - local doc="${1:?Usage: agent_rmse \"doc\" before after}" - local before="${2:?}" - local after="${3:?}" - agent_metric "rmse" "$(printf '{"doc":"%s","before":%s,"after":%s}' "$doc" "$before" "$after")" - - if [ -n "$PROGRESS_FILE" ]; then - printf '{"agent":"%s","ts":"%s","type":"rmse","doc":"%s","rmse_before":%s,"rmse_after":%s}\n' \ - "$AGENT_ID" "$(_agent_ts)" "$doc" "$before" "$after" >> "$PROGRESS_FILE" 2>/dev/null || true - fi -} - -echo "[agent-health] loaded for agent=$AGENT_ID" diff --git a/templates/project-bootstrap/scripts/check-bypass-flags.sh b/templates/project-bootstrap/scripts/check-bypass-flags.sh deleted file mode 100755 index 685d37f..0000000 --- a/templates/project-bootstrap/scripts/check-bypass-flags.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash -# check-bypass-flags.sh — Gate I enforcement: bypass flags require ticket refs. -# -# Source: feedback/processed/2026-04-24-process-gates-G-through-L.md §Gate I. -# -# Rule: any commit whose body mentions a test-bypass flag (--skip-stress, -# --skip-tests, --no-verify, --force, SKIP_TESTS=1, etc.) MUST contain a -# `Bypass tickets:` line listing the broken-test IDs being deferred. Without -# this, the bypass is a quiet escape hatch that ships regressions in -# regression-fix PRs. -# -# The rule covers the COMMIT MESSAGE — it's the audit trail for why a bypass -# was chosen. The actual flag invocation (e.g., wrapping promote-to-prod) is -# project-specific and lives in your project's deploy scripts. -# -# Modes: -# ./scripts/check-bypass-flags.sh # check HEAD's commit msg -# ./scripts/check-bypass-flags.sh # check given file (commit-msg hook usage) -# ./scripts/check-bypass-flags.sh --range .. # audit a range of commits -# ./scripts/check-bypass-flags.sh --warn # flag but don't fail -# -# Exit code: 0 if all qualifying commits are clean, 1 if any violations. -# -# Bash 3.2 compatible. - -set -uo pipefail - -WARN_ONLY=0 -MODE="head" -ARG="" - -while [ $# -gt 0 ]; do - case "$1" in - --warn) WARN_ONLY=1; shift ;; - --range) MODE="range"; ARG="$2"; shift 2 ;; - -h|--help) - sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' - exit 0 - ;; - -*) - echo "check-bypass-flags: unknown flag '$1'" >&2; exit 2 ;; - *) - MODE="file"; ARG="$1"; shift ;; - esac -done - -# Pattern set covering the most common bypass flags. If your project has -# others (e.g., domain-specific --skip-X), extend BYPASS_REGEX in your local -# copy. -# -# Note: we look in the FULL commit body, not the diff. Authors who use a -# bypass flag should mention WHY in the commit body — that's what creates the -# audit trail. If they used the flag silently, that's a separate problem -# (project-specific shell-history tooling). -BYPASS_REGEX='(--skip-(stress|tests|all|e2e|smoke|ci|build|fix|preflight)|--no-verify|--no-gpg-sign|--force[[:space:]]|--force$|SKIP_TESTS=|SKIP_E2E=|SKIP_STRESS=|BYPASS_=)' - -# A commit satisfies Gate I if every bypass mention is paired with a -# `Bypass tickets:` line. -satisfies() { - local body="$1" - echo "$body" | grep -qE '^Bypass[[:space:]]tickets:[[:space:]]*\S' -} - -mentions_bypass() { - local body="$1" - echo "$body" | grep -qE "$BYPASS_REGEX" -} - -check_one() { - local subject="$1" body="$2" label="$3" - if ! mentions_bypass "$body"; then - return 0 # no bypass mentioned - fi - # Documentation/meta commits frequently mention bypass flags as content - # (e.g., "the script catches --skip-stress / --no-verify"). Honor an - # explicit `Bypass-flags-meta: ` opt-out so authors can mark a - # commit as referring to bypass flags rather than invoking them. - if echo "$body" | grep -qE '^Bypass-flags-meta:[[:space:]]*\S'; then - return 0 - fi - if satisfies "$body"; then - return 0 # gate satisfied - fi - echo "" - echo "check-bypass-flags: VIOLATION ($label)" - echo " subject: $subject" - echo "" - echo " Body mentions a test-bypass flag (matched: $(echo "$body" | grep -oE "$BYPASS_REGEX" | head -3 | tr '\n' ' '))" - echo " Gate I requires a line of the form:" - echo " Bypass tickets: TICKET-1, TICKET-2 (one ticket per broken test)" - echo "" - echo " And a justification per ticket. Without this, bypassing a test" - echo " ships an unowned regression. Source: practices/regression-fix-protocol.md §Gate I" - return 1 -} - -violations=0 - -case "$MODE" in - head) - subject="$(git log -1 --format='%s' HEAD 2>/dev/null || true)" - body="$(git log -1 --format='%B' HEAD 2>/dev/null || true)" - if [ -z "$subject" ]; then - echo "check-bypass-flags: no commit found at HEAD" - exit 0 - fi - check_one "$subject" "$body" "HEAD" || violations=$((violations + 1)) - ;; - - file) - if [ ! -f "$ARG" ]; then - echo "check-bypass-flags: file not found: $ARG" >&2 - exit 2 - fi - subject="$(grep -vE '^#|^$' "$ARG" | head -1)" - body="$(cat "$ARG")" - check_one "$subject" "$body" "$ARG" || violations=$((violations + 1)) - ;; - - range) - while IFS= read -r sha; do - [ -z "$sha" ] && continue - subject="$(git log -1 --format='%s' "$sha")" - body="$(git log -1 --format='%B' "$sha")" - check_one "$subject" "$body" "$sha" || violations=$((violations + 1)) - done < <(git rev-list "$ARG" 2>/dev/null) - ;; -esac - -if [ "$violations" -eq 0 ]; then - echo "check-bypass-flags: OK — Gate I satisfied (no bypass mentions without Bypass tickets:)" - exit 0 -fi - -echo "" -echo "check-bypass-flags: $violations violation(s)" -echo "Wire as commit-msg hook: ln -sf ../../scripts/check-bypass-flags.sh .git/hooks/commit-msg" - -if [ "$WARN_ONLY" -eq 1 ]; then - exit 0 -fi -exit 1 diff --git a/templates/project-bootstrap/scripts/check-compliance.sh b/templates/project-bootstrap/scripts/check-compliance.sh deleted file mode 100755 index 7de0718..0000000 --- a/templates/project-bootstrap/scripts/check-compliance.sh +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin/env bash -# check-compliance.sh — Verify rebar compliance: version, tier, README badge, AGENTS.md -# rebar-scripts: 2026.03.20 -# -# Checks: -# 1. .rebar-version file exists and contains a valid semver tag -# 2. .rebarrc file exists and declares a tier (1, 2, or 3) -# 3. README.md has a well-formed rebar badge on the first content line after the title -# 4. Badge version matches .rebar-version -# 5. Badge tier matches .rebarrc tier -# 6. AGENTS.md has required load-bearing sections (Tier 2+) -# 7. AGENTS.md mentions ASK CLI (Tier 2+) -# 8. Federation drift-check wired when CONSUMES.md declares dependencies -# 9. Contract maturity — reads DECLARED `**Status:**` fields from -# architecture/CONTRACT-*.md and weights the badge (v3, Cluster 1): -# <33% stub-or-draft → tier stands as declared -# 33–66% → tier annotated "— IN PROGRESS" (advisory) -# >66% → badge demoted one tier (compliance failure) -# Zero Status: fields anywhere → pre-v3 repo: no penalty, one advisory. -# -# Badge format (must be a blockquote, first line after # Title): -# > **rebar vX.Y.Z** | **Tier N: LEVEL** -# -# Where LEVEL is: PARTIAL (1), ADOPTED (2), or ENFORCED (3) -# -# Usage: ./scripts/check-compliance.sh -# Exit code: 0 = compliant, 1 = non-compliant - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -README="$PROJECT_ROOT/README.md" -AGENTS="$PROJECT_ROOT/AGENTS.md" -VERSION_FILE="$PROJECT_ROOT/.rebar-version" -RC_FILE="$PROJECT_ROOT/.rebarrc" - -errors=0 - -tier_label() { - case "$1" in - 1) echo "PARTIAL" ;; - 2) echo "ADOPTED" ;; - 3) echo "ENFORCED" ;; - *) echo "UNKNOWN" ;; - esac -} - -# ─── Check 1: .rebar-version file ──────────────────────────────────────── - -echo "=== Rebar compliance check ===" -echo "" - -if [ ! -f "$VERSION_FILE" ]; then - echo "FAIL: .rebar-version file not found" - echo " Create it with: echo 'v3.0.0-beta.2' > .rebar-version" - errors=$((errors + 1)) - declared_version="" -else - declared_version=$(cat "$VERSION_FILE" | tr -d '[:space:]') - # Semver with optional pre-release suffix (v3.0.0-beta.2, v3.1.0-rc.1) - if [[ "$declared_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then - echo "OK: .rebar-version = $declared_version" - else - echo "FAIL: .rebar-version contains '$declared_version' — expected format: vX.Y.Z or vX.Y.Z-prerelease" - errors=$((errors + 1)) - fi -fi - -# ─── Check 2: .rebarrc tier ────────────────────────────────────────────── - -if [ ! -f "$RC_FILE" ]; then - echo "FAIL: .rebarrc file not found" - echo " Create it from .rebarrc.template" - errors=$((errors + 1)) - declared_tier="" -else - # '|| true' — under errexit a .rebarrc with no tier line must reach the - # FAIL message below, not kill the script mid-pipeline with no diagnostic. - declared_tier=$(grep '^tier' "$RC_FILE" 2>/dev/null | head -1 | sed 's/.*=[[:space:]]*//' | tr -d ' ' || true) - if [[ "$declared_tier" =~ ^[123]$ ]]; then - echo "OK: .rebarrc tier = $declared_tier ($(tier_label "$declared_tier"))" - else - echo "FAIL: .rebarrc tier is '$declared_tier' — expected 1, 2, or 3" - errors=$((errors + 1)) - fi -fi - -# ─── Check 3: README.md rebar badge ────────────────────────────────────── - -if [ ! -f "$README" ]; then - echo "FAIL: README.md not found" - errors=$((errors + 1)) -else - # Find the badge line: first blockquote line containing "rebar v" after the title - badge_line=$(grep -n '^\s*>\s*\*\*rebar v' "$README" | head -1 || true) - - if [ -z "$badge_line" ]; then - echo "FAIL: README.md has no rebar badge" - echo " Add this as the first line after your # Title:" - echo ' > **rebar v3.0.0-beta.2** | **Tier 2: ADOPTED**' - errors=$((errors + 1)) - else - line_num=$(echo "$badge_line" | cut -d: -f1) - line_content=$(echo "$badge_line" | cut -d: -f2-) - - # Validate format: > **rebar vX.Y.Z[-prerelease]** | **Tier N: LEVEL** - if [[ "$line_content" =~ \*\*rebar\ (v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?)\*\*.*\*\*Tier\ ([0-9]+):\ ([A-Z]+)\*\* ]]; then - badge_version="${BASH_REMATCH[1]}" - badge_tier="${BASH_REMATCH[3]}" - badge_level="${BASH_REMATCH[4]}" - - echo "OK: README.md badge found on line $line_num" - echo " Version: $badge_version | Tier: $badge_tier ($badge_level)" - - # Check badge is near the top (within first 5 lines) - if [ "$line_num" -gt 5 ]; then - echo "WARN: Badge is on line $line_num — should be within the first 3 lines (right after # Title)" - fi - - # ─── Check 4: Badge version matches .rebar-version ────────── - if [ -n "$declared_version" ] && [ "$badge_version" != "$declared_version" ]; then - echo "FAIL: Badge says $badge_version but .rebar-version says $declared_version" - errors=$((errors + 1)) - fi - - # ─── Check 5: Badge tier matches .rebarrc tier ────────────── - if [ -n "$declared_tier" ] && [ "$badge_tier" != "$declared_tier" ]; then - echo "FAIL: Badge says Tier $badge_tier but .rebarrc says tier = $declared_tier" - errors=$((errors + 1)) - fi - - # Check level matches tier number - expected_level=$(tier_label "$badge_tier") - if [ "$badge_level" != "$expected_level" ]; then - echo "FAIL: Tier $badge_tier should be '$expected_level', not '$badge_level'" - errors=$((errors + 1)) - fi - else - echo "FAIL: README.md badge is malformed on line $line_num" - echo " Found: $line_content" - echo ' Expected: > **rebar vX.Y.Z** | **Tier N: LEVEL**' - echo ' Where LEVEL is PARTIAL (1), ADOPTED (2), or ENFORCED (3)' - errors=$((errors + 1)) - fi - fi -fi - -# ─── Check 6: AGENTS.md load-bearing sections (Tier 2+) ────────────────── - -# Source tier config if available -if [ -f "$SCRIPT_DIR/_rebar-config.sh" ]; then - source "$SCRIPT_DIR/_rebar-config.sh" - current_tier=$(_rebar_tier) -else - current_tier="${declared_tier:-3}" -fi - -if [ "$current_tier" -ge 2 ] && [ -f "$AGENTS" ]; then - echo "" - echo "=== AGENTS.md required sections ===" - - # These are the load-bearing walls. Without them, agents don't know - # about contracts, testing discipline, or the TODO system. - required_sections=( - "Cold Start\|Read Before Coding:Cold Start / Read Before Coding — agents must know the reading order" - "Contract-Driven\|Contract.Driven:Contract-Driven Development — the 4 rules that make contracts operational" - "Testing Cascade\|Testing Expectations\|Scout Rule:Testing expectations — cascade tiers or scout rule" - "TODO Tracking:TODO Tracking — two-tag system prevents invisible debt" - ) - - for entry in "${required_sections[@]}"; do - pattern="${entry%%:*}" - description="${entry##*:}" - - if grep -qi "$pattern" "$AGENTS" 2>/dev/null; then - echo "OK: $description" - else - echo "FAIL: AGENTS.md missing required section: $description" - errors=$((errors + 1)) - fi - done - - # Check 7: ASK CLI awareness — agents need to know they can query role-based agents - if grep -qi '\bask\b\|ASK CLI\|ask architect\|ask product\|ask steward\|ask englead' "$AGENTS" 2>/dev/null; then - echo "OK: AGENTS.md mentions ASK CLI" - else - echo "FAIL: AGENTS.md does not mention ASK CLI" - echo " Agents need to know they can use 'ask \"question\"' for focused queries." - echo " Add a reference in the Cold Start or Reference section." - errors=$((errors + 1)) - fi - -elif [ "$current_tier" -ge 2 ] && [ ! -f "$AGENTS" ]; then - echo "" - echo "FAIL: AGENTS.md not found (required at Tier 2+)" - errors=$((errors + 1)) -fi - -# ─── Check 8: Federation — drift-check wired when CONSUMES.md exists ───── -# -# CHARTER §1.6 makes federation opt-in but compliance-gated: once a repo -# adds a CONSUMES.md (declaring cross-repo dependencies), it MUST run -# `rebar contract drift-check` in CI so consumers don't silently age out -# while owner contracts evolve. Adopters self-select by adding the file. - -CONSUMES_FILE="$PROJECT_ROOT/CONSUMES.md" -if [ -f "$CONSUMES_FILE" ]; then - echo "" - echo "=== Federation (CONSUMES.md present) ===" - - # Has at least one real entry (## owner/contract.version section)? - # Count REAL declarations only — a fenced code block documenting the format - # is not a dependency. The bootstrap's own CONSUMES.md carries five worked - # examples inside a ```markdown fence; a line-anchored grep counted all five, - # so every freshly-bootstrapped project failed this check out of the box - # ("declares dependencies but drift-check is not wired") without ever having - # declared anything. sch-repos named this class exactly, in another context, - # the same day: an anchored regex cannot distinguish a real declaration from - # a document quoting one. Toggling on ``` fences is the cheap correct fix. - consumes_entries=$(awk ' - /^```/ { fence = !fence; next } - !fence && /^## [A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+$/ { n++ } - END { print n + 0 } - ' "$CONSUMES_FILE" 2>/dev/null | tr -d '[:space:]' || echo 0) - if [ "${consumes_entries:-0}" = "0" ]; then - echo "OK: CONSUMES.md present but no entries declared (federation opt-in not yet active)" - else - echo "OK: CONSUMES.md declares $consumes_entries cross-repo dependency(ies)" - - # drift-check must be wired into a CI-relevant script. Check the - # standard rebar surfaces in priority order. An adopter can override - # by adding their own framework (Makefile, GitHub Actions, etc.) — - # we look across the obvious places. - drift_wired=0 - for f in "$PROJECT_ROOT/scripts/ci-check.sh" "$PROJECT_ROOT/scripts/pre-commit.sh" "$PROJECT_ROOT/Makefile" "$PROJECT_ROOT/.github/workflows"/*; do - [ -e "$f" ] || continue - if [ -d "$f" ]; then - if grep -rq "drift-check" "$f" 2>/dev/null; then - drift_wired=1 - break - fi - elif grep -q "drift-check" "$f" 2>/dev/null; then - drift_wired=1 - break - fi - done - - if [ "$drift_wired" -eq 1 ]; then - echo "OK: drift-check is wired into CI" - else - echo "FAIL: CONSUMES.md declares dependencies but \`rebar contract drift-check\` is not wired into CI" - echo " Add to scripts/ci-check.sh (or your CI of choice):" - echo " rebar contract drift-check" - echo " Without this, consumed contracts can silently age out as upstream evolves (CHARTER §1.6)." - errors=$((errors + 1)) - fi - fi -fi - -# ─── Check 9: Contract maturity (declared Status: fields) ──────────────── -# -# v3 maturity honesty (docs/v3-beta-plan.md Cluster 1): contracts DECLARE -# maturity in a `**Status:** ` header line — stub / draft / -# in-progress / active / verified. This is the human/agent honesty marker, -# distinct from the Steward's COMPUTED lifecycle -# (draft/active/testing/impl-present), which this check never reads. -# The badge is weighted by how much of the live contract set is still -# stub-or-draft, so a tier can't sit on top of placeholder contracts. - -ARCH_DIR="$PROJECT_ROOT/architecture" - -if [ -d "$ARCH_DIR" ]; then - echo "" - echo "=== Contract maturity (declared Status:) ===" - - status_total=0 # Status: fields found across all contract files - live_total=0 # live (non-superseded) contracts considered - live_declared=0 # live contracts with a recognized maturity value - stub_draft=0 # live contracts declared stub or draft - missing_status="" # newline-joined names of live contracts without Status: - - for contract in "$ARCH_DIR"/CONTRACT-*.md; do - [ -f "$contract" ] || continue - cbase="$(basename "$contract")" - - # Skip templates, the generated registry, and companion files — - # same exclusions as compute-registry.sh. - case "$cbase" in - CONTRACT-TEMPLATE.md|CONTRACT-SEAM-TEMPLATE.md|CONTRACT-REGISTRY.md|CONTRACT-REGISTRY.template.md|CONTRACT-GAPS.md) - continue ;; - *.impl.md) - continue ;; - esac - - # Tolerant parse, identical to cold-start-checks.sh: bolded or bare - # 'Status:' line, first word, case-folded. The canonical form stays - # '**Status:** value' (conventions.md) but parsers of record must agree — - # and conventions.md now publishes THIS regex verbatim so prose cannot - # drift from the parser again. - # - # The strip is a single asterisk-tolerant expression rather than two - # fixed alternatives. The old chain matched '^\*{0,2}Status:' but only - # stripped the two-asterisk and bare forms, so '*Status:* active' fell - # through with its marker intact, awk yielded '*Status:*', and the value - # landed on the '*)' arm — silently counted as stub-or-draft. A - # formatting slip became a badge penalty explained only by a WARN. - # See feedback/2026-08-02-sch-repos-canonical-status-grep-*.md - cstatus_raw="$(grep -m1 -E '^\*{0,2}Status:' "$contract" 2>/dev/null || true)" - cstatus="$(printf '%s' "$cstatus_raw" \ - | sed -E 's/^\*{0,2}Status:\*{0,2}//' \ - | awk '{print $1}' | tr -d '*' | tr '[:upper:]' '[:lower:]' || true)" - - if [ -n "$cstatus" ]; then - status_total=$((status_total + 1)) - fi - - # Terminal states are out of the live maturity mix — a superseded - # contract kept around for its migration window shouldn't drag (or - # inflate) the badge. - case "$cstatus" in - superseded|deprecated|retired) continue ;; - esac - - live_total=$((live_total + 1)) - - case "$cstatus" in - "") - # Warned about later — only when the repo has *some* Status: fields - # (partially migrated). A pre-v3 repo gets one advisory, not N warns. - # Once any contract declares, undeclared live contracts COUNT AS - # stub-or-draft: selective declaration must not bypass demotion. - missing_status="${missing_status}${cbase} -" - ;; - stub|draft) - live_declared=$((live_declared + 1)) - stub_draft=$((stub_draft + 1)) - ;; - in-progress|active|verified) - live_declared=$((live_declared + 1)) - ;; - *) - # Report the RAW line, not just the parsed token. For a near-miss - # format the parsed value is mangled, and a WARN naming the mangled - # value is not actionable — the author cannot see what to change. - echo "WARN: $cbase declares Status: '$cstatus' — not in the maturity vocabulary (stub|draft|in-progress|active|verified); counted as stub-or-draft" - echo " source line: $(printf '%s' "$cstatus_raw" | head -c 120)" - stub_draft=$((stub_draft + 1)) - ;; - esac - done - - if [ "$status_total" -eq 0 ]; then - if [ "$live_total" -gt 0 ]; then - echo "ADVISORY: no contract declares a Status: field — treating as a pre-v3 repo (no maturity penalty)." - echo " Add '**Status:** ' to each contract header" - echo " (see architecture/CONTRACT-TEMPLATE.md) so the badge can reflect real maturity." - else - echo "OK: no contracts found — maturity weighting not applicable" - fi - elif [ -n "$missing_status" ]; then - # Partially migrated: some contracts declare Status:, these don't. - printf '%s' "$missing_status" | while IFS= read -r mname; do - echo "WARN: $mname has no Status: line — add one (see architecture/CONTRACT-TEMPLATE.md header)" - done - fi - - if [ "$status_total" -gt 0 ] && [ "$live_declared" -eq 0 ] && [ "$live_total" -eq 0 ]; then - echo "OK: no live contracts declare maturity (all terminal) — no maturity weighting" - elif [ "$status_total" -gt 0 ] && [ "$live_total" -gt 0 ]; then - # Weight over ALL live contracts: undeclared ones already counted as - # stub-or-draft above (selective declaration must not launder a badge). - undeclared=$((live_total - live_declared)) - if [ "$undeclared" -gt 0 ]; then - stub_draft=$((stub_draft + undeclared)) - fi - pct_tenths=$((stub_draft * 1000 / live_total)) - pct="$((pct_tenths / 10)).$((pct_tenths % 10))" - echo "OK: $live_declared of $live_total live contract(s) declare maturity — $stub_draft stub-or-draft-or-undeclared (${pct}%)" - - if [[ "$declared_tier" =~ ^[123]$ ]]; then - # Product comparisons — integer division floor must not soften the - # documented thresholds (<33% ok, 33-66% annotate, >66% demote). - # - # DEGENERATE POPULATION GUARD (sch-repos, 2026-08-02). At live_total=1 - # a percentage quantizes to exactly 0% or 100%: the middle NOTE band is - # mathematically unreachable, so the only honest downgrade available to - # a one-contract repo jumps straight from OK to FAIL + tier demotion. - # That made dishonesty the green path — declaring your single contract - # `draft` (the honest value while it drifts from its implementation) - # failed CI, while leaving it `active` passed. Exactly the inversion v3 - # exists to remove, and it lands on new/small adopters — the on-ramp. - # - # Threshold is -lt 2 because n=1 is precisely the degenerate case; from - # n=2 the NOTE band is reachable and the formula behaves as documented. - # 100%-stub still fails at every n>=2, so anti-laundering is intact. - # Gameable in principle by deleting contracts to drop under it — but - # shrinking your governance surface to dodge a badge is self-defeating, - # visible in the diff, and leaves the repo in the state the advisory - # already prints. - # See feedback/2026-08-02-sch-repos-check-compliance-maturity-*.md - if [ "$live_total" -lt 2 ]; then - echo "NOTE: only $live_total live contract(s) — maturity is advisory at this population" - echo " (a percentage over n=1 quantizes to 0% or 100%; badge not weighted)" - elif [ $((stub_draft * 100)) -lt $((live_total * 33)) ]; then - echo "OK: maturity supports the declared badge (Tier $declared_tier: $(tier_label "$declared_tier"))" - elif [ $((stub_draft * 100)) -le $((live_total * 66)) ]; then - echo "NOTE: ${pct}% of live contracts are stub-or-draft — badge reads as" - echo " 'Tier $declared_tier: $(tier_label "$declared_tier") — IN PROGRESS' until the set matures" - else - demoted_tier=$((declared_tier - 1)) - echo "FAIL: ${pct}% of live contracts are stub-or-draft (>66%) — badge demoted one tier" - if [ "$demoted_tier" -ge 1 ]; then - echo " Declared: Tier $declared_tier: $(tier_label "$declared_tier") — Effective: Tier $demoted_tier: $(tier_label "$demoted_tier")" - else - echo " Declared: Tier $declared_tier: $(tier_label "$declared_tier") — Effective: below Tier 1 (adoption not yet real)" - fi - echo " Reason: a tier claimed on top of a mostly stub/draft contract set overstates adoption." - echo " Fix: mature the contracts (or lower the badge) until <=66% are stub-or-draft." - errors=$((errors + 1)) - fi - else - echo "NOTE: no valid tier declared — maturity computed but badge weighting skipped" - fi - fi -fi - -# ─── Summary ────────────────────────────────────────────────────────────── - -echo "" -if [ "$errors" -gt 0 ]; then - echo "FAIL: $errors compliance issue(s) found." - exit 1 -else - echo "OK: Rebar compliance verified." - exit 0 -fi diff --git a/templates/project-bootstrap/scripts/check-contract-headers.sh b/templates/project-bootstrap/scripts/check-contract-headers.sh deleted file mode 100755 index 627e47c..0000000 --- a/templates/project-bootstrap/scripts/check-contract-headers.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# check-contract-headers.sh — Verify every source file has a CONTRACT: or Architecture: header -# rebar-scripts: 2026.03.20 -# -# Usage: ./scripts/check-contract-headers.sh [directories...] -# Default: scans src/ internal/ cmd/ client/ packages/ lib/ app/ -# -# Exit code: 0 = all files have headers, 1 = missing headers found - -set -euo pipefail - -# Tier gate: contract headers are Tier 2+ (skip for Tier 1 / partial adoption) -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -[ -f "$SCRIPT_DIR/_rebar-config.sh" ] && source "$SCRIPT_DIR/_rebar-config.sh" && _rebar_skip 2 && exit 0 - -# Configurable: file extensions to check -EXTENSIONS="${CONTRACT_EXTENSIONS:-.go .ts .tsx .js .jsx .py .rs}" - -# Configurable: directories to scan (override with args) -if [ $# -gt 0 ]; then - DIRS=("$@") -else - DIRS=() - for d in src internal cmd client packages lib app; do - [ -d "$d" ] && DIRS+=("$d") - done -fi - -if [ ${#DIRS[@]} -eq 0 ]; then - echo "No source directories found. Pass directories as arguments." - exit 0 -fi - -# Build find expression for extensions -FIND_ARGS=() -first=true -for ext in $EXTENSIONS; do - if $first; then - FIND_ARGS+=(-name "*${ext}") - first=false - else - FIND_ARGS+=(-o -name "*${ext}") - fi -done - -missing=0 -total=0 - -while IFS= read -r file; do - # Skip test files, generated files, vendor, node_modules - case "$file" in - *_test.go|*.test.ts|*.test.tsx|*.test.js|*.spec.ts|*.spec.tsx|*.spec.js) continue ;; - */vendor/*|*/node_modules/*|*/dist/*|*/build/*|*/.git/*) continue ;; - *_generated*|*.gen.*|*.pb.go|*.pb.ts) continue ;; - esac - - total=$((total + 1)) - - # Check first 15 lines for CONTRACT: or Architecture: - if ! head -15 "$file" | grep -q "CONTRACT:\|Architecture:"; then - echo "MISSING: $file" - missing=$((missing + 1)) - fi -done < <(find "${DIRS[@]}" \( "${FIND_ARGS[@]}" \) -type f 2>/dev/null) - -echo "" -echo "Scanned $total source files, $missing missing contract headers." - -if [ "$missing" -gt 0 ]; then - # NOTE: literal CONTRACT prefix split via variable so the shadow-ref - # detector in compute-registry.sh doesn't false-positive on these example - # strings. See feedback/processed/2026-04-25-bootstrap-template-script-drift-and-bash3.2.md - # for the related cli/cmd/context.go fix. - P="CONTRACT:" - echo "" - echo "Every source file must declare which contract it implements:" - echo " // ${P}C1-BLOBSTORE.2.1" - echo " // Architecture: ${P}S2-API-GATEWAY.1.0" - echo "" - echo "See architecture/README.md for the full convention." - exit 1 -fi - -exit 0 diff --git a/templates/project-bootstrap/scripts/check-contract-refs.sh b/templates/project-bootstrap/scripts/check-contract-refs.sh deleted file mode 100755 index 8cacaaf..0000000 --- a/templates/project-bootstrap/scripts/check-contract-refs.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# check-contract-refs.sh — Verify every CONTRACT: reference points to a real file -# rebar-scripts: 2026.03.20 -# -# Usage: ./scripts/check-contract-refs.sh [architecture-dir] -# Default: architecture/ -# -# Exit code: 0 = all refs valid, 1 = broken refs found - -set -euo pipefail - -ARCH_DIR="${1:-architecture}" - -if [ ! -d "$ARCH_DIR" ]; then - echo "Architecture directory '$ARCH_DIR' not found." - exit 1 -fi - -broken=0 -total=0 - -# Find all CONTRACT: references in source files -while IFS= read -r line; do - file=$(echo "$line" | cut -d: -f1) - lineno=$(echo "$line" | cut -d: -f2) - - # Extract the contract ID (e.g., C1-BLOBSTORE.2.1) - ref=$(echo "$line" | grep -o 'CONTRACT:[A-Za-z0-9_-]*\.[0-9]*\.[0-9]*' | head -1 | sed 's/CONTRACT://') - - [ -z "$ref" ] && continue - total=$((total + 1)) - - # Check if the contract file exists - expected="${ARCH_DIR}/CONTRACT-${ref}.md" - if [ ! -f "$expected" ]; then - echo "BROKEN: $file:$lineno references CONTRACT:$ref" - echo " Expected: $expected" - broken=$((broken + 1)) - fi -done < <(grep -rn "CONTRACT:[A-Za-z0-9_-]*\.[0-9]*\.[0-9]*" \ - --include="*.go" --include="*.ts" --include="*.tsx" --include="*.js" \ - --include="*.py" --include="*.rs" --include="*.jsx" \ - . 2>/dev/null | grep -v "node_modules\|vendor\|dist\|\.git") - -echo "" -echo "Checked $total contract references, $broken broken." - -if [ "$broken" -gt 0 ]; then - echo "" - echo "Fix by either:" - echo " 1. Creating the missing contract in $ARCH_DIR/" - echo " 2. Updating the code reference to the correct contract version" - exit 1 -fi - -exit 0 diff --git a/templates/project-bootstrap/scripts/check-decay-patterns.sh b/templates/project-bootstrap/scripts/check-decay-patterns.sh deleted file mode 100755 index e9e5737..0000000 --- a/templates/project-bootstrap/scripts/check-decay-patterns.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bash -# check-decay-patterns.sh — Flag soft-hardening patterns that look like -# done work but ship a longer-fuse failure mode. -# -# Sources: rebar/feedback/2026-04-24-fidelity-decay-soft-hardening-patterns.md -# rebar/feedback/processed/2026-04-27-e2e-test-bypass-closed-loop-verification-drift.md (P8) -# -# An author cannot reliably self-audit hardening work because the author's -# context (right now, with the test fresh) matches the consumer's context -# (six months from now, scanning a dashboard). This script applies external -# state — a structural lens — at commit time. -# -# Eight named patterns from the fidelity-decay feedback. The grep-detectable -# subset is implemented; the semantic ones (hermeticity, env-name -# plausibility) are left for the author's self-audit prompt linked from -# AGENTS.template.md. P8 (demo-spec bypass) is the banned-pattern gate from -# the e2e-bypass feedback: demo-claiming and uaks-declared specs must drive -# the surface a user touches (see practices/test-fidelity.md, Gate 2). -# -# Modes: -# ./scripts/check-decay-patterns.sh # scan staged files -# ./scripts/check-decay-patterns.sh --all # scan everything tracked -# ./scripts/check-decay-patterns.sh --paths a b # scan specific paths -# ./scripts/check-decay-patterns.sh --warn # flag but don't fail -# -# Exit code: 0 if no findings (or --warn), 1 if findings in default mode. -# -# Bash 3.2 compatible (macOS default). - -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ALLOWLIST="$PROJECT_ROOT/.rebar/decay-patterns-allow.txt" - -MODE="staged" -WARN_ONLY=0 -declare -a EXPLICIT_PATHS=() - -while [ $# -gt 0 ]; do - case "$1" in - --all) MODE="all"; shift ;; - --paths) MODE="explicit"; shift; while [ $# -gt 0 ] && [ "${1:0:2}" != "--" ]; do EXPLICIT_PATHS+=("$1"); shift; done ;; - --warn) WARN_ONLY=1; shift ;; - -h|--help) - sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' - exit 0 - ;; - *) echo "check-decay-patterns: unknown arg '$1'" >&2; exit 2 ;; - esac -done - -cd "$PROJECT_ROOT" - -# Resolve the file set to scan. -declare -a FILES=() -case "$MODE" in - staged) - while IFS= read -r f; do FILES+=("$f"); done < <(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null) - if [ ${#FILES[@]} -eq 0 ]; then - # Fall back to working-tree changes (useful when running outside a hook). - while IFS= read -r f; do FILES+=("$f"); done < <(git diff --name-only --diff-filter=ACMR 2>/dev/null) - fi - ;; - all) - while IFS= read -r f; do FILES+=("$f"); done < <(git ls-files) - ;; - explicit) - FILES=("${EXPLICIT_PATHS[@]+"${EXPLICIT_PATHS[@]}"}") - ;; -esac - -if [ ${#FILES[@]} -eq 0 ]; then - echo "check-decay-patterns: no files to scan." - exit 0 -fi - -# Filter to spec/test files + workflow files. Other files don't have these -# patterns and would just produce noise. -declare -a SCAN_FILES=() -for f in "${FILES[@]+"${FILES[@]}"}"; do - [ -f "$f" ] || continue - case "$f" in - *.spec.ts|*.spec.tsx|*.spec.js|*.spec.jsx|*.spec.mjs) SCAN_FILES+=("$f") ;; - *.test.ts|*.test.tsx|*.test.js|*.test.jsx|*.test.mjs) SCAN_FILES+=("$f") ;; - *_test.go|*_spec.rb|*_test.py|*test_*.py) SCAN_FILES+=("$f") ;; - *playwright*.config.*|*vitest*.config.*|*jest*.config.*) SCAN_FILES+=("$f") ;; - .github/workflows/*.yml|.github/workflows/*.yaml) SCAN_FILES+=("$f") ;; - esac -done - -if [ ${#SCAN_FILES[@]} -eq 0 ]; then - echo "check-decay-patterns: no spec/config/workflow files in scan set." - exit 0 -fi - -# Findings: ::: -findings_tmp="$(mktemp)" -trap 'rm -f "$findings_tmp"' EXIT -: > "$findings_tmp" - -# Helper: scan with a regex, emit findings. -scan() { - local pattern_id="$1" regex="$2" message="$3" file - shift 3 - for file in "$@"; do - [ -f "$file" ] || continue - # Use grep -nE; allow pattern to fail gracefully on no-match. - while IFS=: read -r lineno _; do - [ -z "$lineno" ] && continue - # Allowlist check by file:line:pattern triple. - if [ -f "$ALLOWLIST" ] && grep -Fxq -- "${file}:${lineno}:${pattern_id}" "$ALLOWLIST" 2>/dev/null; then - continue - fi - echo "${file}:${lineno}:${pattern_id}:${message}" >> "$findings_tmp" - done < <(grep -nEi "$regex" "$file" 2>/dev/null || true) - done -} - -# ------------------------------------------------------------------------- -# Pattern 1 — Silenced failures -# testInfo.fail() / test.fail() / it.fail() / expect.fail() / .skip() with TODO -# ------------------------------------------------------------------------- -scan "P1-silenced-failure" \ - '(testInfo\.fail|test\.fail|it\.fail|describe\.fail|expect\.fail)\(' \ - "Pattern 1 (silenced failure): use a real failing assertion + CI continue-on-error instead — silencing the test makes the bug invisible to anyone glancing at the dashboard." \ - "${SCAN_FILES[@]}" - -# ------------------------------------------------------------------------- -# Pattern 2 — Inverted assertion (heuristic — flags for review) -# `.toBe(null)` / `.toBeNull()` / `.toEqual([])` / `.not.toContain` in security/audit specs -# ------------------------------------------------------------------------- -declare -a SEC_FILES=() -for f in "${SCAN_FILES[@]+"${SCAN_FILES[@]}"}"; do - case "$f" in - *security*|*audit*|*regression*|*detector*) SEC_FILES+=("$f") ;; - esac -done -if [ ${#SEC_FILES[@]} -gt 0 ]; then - scan "P2-inverted-assertion" \ - '\.(toBe\(null\)|toBeNull\(\)|toEqual\(\[\]\)|not\.toContain)' \ - "Pattern 2 (inverted assertion): assertion passes when the thing being tested is broken — a future engineer 'fixing' this test will silently elide the signal. Restate positively + use expected-fail at the workflow level. Or pair with a negative-control test that stages the violation." \ - "${SEC_FILES[@]}" -fi - -# ------------------------------------------------------------------------- -# Pattern 4 — Hand-copied "keep in sync" data -# Comments containing `keep .* in sync` / `update when` / `mirrors` -# ------------------------------------------------------------------------- -scan "P4-keep-in-sync" \ - '(keep[[:space:]]+(this|in|them)[[:space:]]+(in[[:space:]]+)?sync|update[[:space:]]+when[[:space:]]+upgrading|mirrors[[:space:]]+the[[:space:]]+upstream)' \ - "Pattern 4 (silent drift on upgrade): a comment saying 'keep in sync' is a wish. Read the source of truth at runtime and throw on parse failure, instead of hand-copying." \ - "${SCAN_FILES[@]}" - -# ------------------------------------------------------------------------- -# Pattern 5 — Magic-string project/name gating -# `testInfo.project.name === 'literal'` or `project.name == 'literal'` -# ------------------------------------------------------------------------- -scan "P5-magic-string-gating" \ - "testInfo\.project\.name[[:space:]]*[!=]==?[[:space:]]*['\"]" \ - "Pattern 5 (magic-string gating): renaming the project silently breaks the gate, no compile error. Use testInfo.project.metadata. instead — moving metadata with the project definition makes the coupling explicit." \ - "${SCAN_FILES[@]}" - -# ------------------------------------------------------------------------- -# Pattern 7 — Plausible test-only env var names (heuristic) -# VITE_ALLOW_*, *_ALLOW_HOST, *_DISABLE_AUTH — generic-sounding test loosening -# ------------------------------------------------------------------------- -scan "P7-plausible-env-name" \ - '(VITE_ALLOW_ALL|ALLOW_ALL_HOSTS|DISABLE_AUTH|SKIP_VERIFY|BYPASS_(AUTH|TLS|CORS))' \ - "Pattern 7 (too-plausible-to-refuse): this env var name reads like normal infrastructure config — a deploy script copy-paste won't be flagged. Make the name long and obviously test-only (e.g., FOO_PLAYWRIGHT_REAL_CHROME_LOOSENING) and require a second test-only env var to activate." \ - "${SCAN_FILES[@]}" - -# ------------------------------------------------------------------------- -# Pattern 8 — Demo/UAKS spec bypass (silenced failure by state injection) -# Demo-claiming specs (demo-*.spec.*, ui-demo-*.spec.*) and specs declaring -# `fidelity: uaks` must drive the surface a user touches. Auth/state -# injection, network mocks, or API-direct user actions in those files make -# a green run a costume, not evidence — the login page can be frozen while -# the suite reports 8/8. Doctrine: practices/test-fidelity.md (Gate 2). -# ------------------------------------------------------------------------- -declare -a DEMO_FILES=() -for f in "${SCAN_FILES[@]+"${SCAN_FILES[@]}"}"; do - case "$f" in - *.config.*|.github/workflows/*) continue ;; - esac - demo_base="${f##*/}" - case "$demo_base" in - demo-*|ui-demo-*|*uaks*|*user-at-keyboard*) - DEMO_FILES+=("$f") ;; - *) - # A `fidelity: uaks` declaration opts the file in wherever it lives. - if grep -qE '(//|#|--)[[:space:]]*fidelity:[[:space:]]*uaks' "$f" 2>/dev/null; then - DEMO_FILES+=("$f") - fi - ;; - esac -done -if [ ${#DEMO_FILES[@]} -gt 0 ]; then - scan "P8-demo-bypass" \ - '(loginAs[[:space:]]*\(|installSessionCookie|userManager\.storeUser|sessionStorage\.setItem|localStorage\.setItem|addInitScript|addCookies[[:space:]]*\(|page\.route[[:space:]]*\(|route\.fulfill|page\.evaluate|request\.(post|get|put|delete)[[:space:]]*\()' \ - "Pattern 8 (demo-spec bypass): a demo-claiming or uaks-declared spec must use only what a user at a keyboard could do — no state injection, no network mocks, no API-direct user actions. Move the shortcut to a lower-fidelity spec (and name the helper STUB_/INJECT_/BYPASS_), or drop the demo claim. See practices/test-fidelity.md." \ - "${DEMO_FILES[@]}" -fi - -# ------------------------------------------------------------------------- -# Report -# ------------------------------------------------------------------------- -count="$(wc -l < "$findings_tmp" | tr -d ' ')" - -if [ "$count" -eq 0 ]; then - echo "check-decay-patterns: OK — no soft-hardening patterns detected in scan set." - exit 0 -fi - -echo "check-decay-patterns: $count finding(s) in scan set" -echo "" -echo "Each line: :::" -echo "" -cat "$findings_tmp" -echo "" -echo "If a finding is intentional + reviewed, allowlist by adding" -echo " ::" -echo "to .rebar/decay-patterns-allow.txt (one per line, # for comments)." -echo "" -echo "References: feedback/2026-04-24-fidelity-decay-soft-hardening-patterns.md" -echo " feedback/processed/2026-04-27-e2e-test-bypass-closed-loop-verification-drift.md (P8)" -echo " practices/test-fidelity.md (fidelity ladder + gates)" - -if [ "$WARN_ONLY" -eq 1 ]; then - exit 0 -fi -exit 1 diff --git a/templates/project-bootstrap/scripts/check-doc-refs.sh b/templates/project-bootstrap/scripts/check-doc-refs.sh deleted file mode 100755 index a24670f..0000000 --- a/templates/project-bootstrap/scripts/check-doc-refs.sh +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env bash -# check-doc-refs.sh — Verify that every file referenced from a tracked *.md -# is itself tracked in git. -# -# Catches a class of drift Tier 2 ci-check.sh misses: a load-bearing doc -# (e.g., FEDERATION-STORIES-DRAFT.md) is added to the working tree, cited -# from another doc, but never `git add`-ed. The current repo passes; a -# fresh clone fails to resolve the link. -# -# Source: rebar/feedback/2026-04-21-filedag-cross-ref-and-federation-coord.md §1 -# -# Usage: -# ./scripts/check-doc-refs.sh # report broken refs, exit 1 if any -# ./scripts/check-doc-refs.sh --quiet # only print failures -# ./scripts/check-doc-refs.sh --json # JSON summary on stdout -# -# Heuristics: -# - Walks every tracked *.md file (`git ls-files '*.md'`). -# - Extracts markdown-link targets: `[text](TARGET)`. -# - Skips: external URLs (http*, mailto, tel, ftp), anchor-only refs (#x), -# home-relative refs (~/...), template placeholders ({...}), allowlisted -# paths from .rebar/doc-refs-allow.txt (one path per line, # for comments). -# - Resolves remaining targets relative to the source file's directory. -# - Fails if resolved target is not in `git ls-files`. -# -# Bash 3.2 compatible (macOS default). - -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ALLOWLIST="$PROJECT_ROOT/.rebar/doc-refs-allow.txt" - -MODE="text" -case "${1:-}" in - --quiet) MODE="quiet" ;; - --json) MODE="json" ;; - -h|--help) - sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' - exit 0 - ;; -esac - -if ! command -v git >/dev/null 2>&1; then - echo "check-doc-refs: git not found" >&2 - exit 2 -fi - -cd "$PROJECT_ROOT" - -# Load allowlist into a sorted file for grep -Fx checks. Empty if missing. -allowlist_tmp="$(mktemp)" -trap 'rm -f "$allowlist_tmp" "$tracked_tmp" "$findings_tmp"' EXIT -if [ -f "$ALLOWLIST" ]; then - grep -v '^#' "$ALLOWLIST" 2>/dev/null | grep -v '^$' | sort -u > "$allowlist_tmp" || true -fi - -# Snapshot all tracked files once; lookups become a sorted-file grep. -tracked_tmp="$(mktemp)" -git ls-files | sort -u > "$tracked_tmp" - -# Findings accumulator: one line per broken ref. -# Format: :: -findings_tmp="$(mktemp)" -: > "$findings_tmp" - -# Iterate every tracked *.md. -total_refs=0 -broken_refs=0 - -while IFS= read -r src; do - # Skip files that no longer exist on disk (stale worktrees, deletions - # not yet committed). git ls-files reports tracked entries even if the - # working-tree copy is missing. - [ -f "$src" ] || continue - - # Skip generated files. - if head -3 "$src" 2>/dev/null | grep -qE 'AUTO-GENERATED| markers -# and flags any that are older than the threshold. -# -# Exit code: 0 = all fresh, 1 = stale docs found - -set -euo pipefail - -# Tier gate: freshness checks are Tier 2+ (skip for Tier 1 / partial adoption) -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -[ -f "$SCRIPT_DIR/_rebar-config.sh" ] && source "$SCRIPT_DIR/_rebar-config.sh" && _rebar_skip 2 && exit 0 - -MAX_AGE_DAYS="${1:-14}" -TODAY=$(date +%s) -stale=0 -total=0 -unmarked=0 - -while IFS= read -r file; do - # Skip template files — their freshness markers are placeholders that - # adopters update on copy. Holding them to the freshness threshold - # produces noise, not signal. - case "$file" in - ./templates/*) continue ;; - esac - - # Honor an explicit "freshness: archived" opt-out for historical - # retrospectives, decided design docs, and other intentionally-static - # content. The literal token is searched before the date pattern. - if grep -q 'freshness: archived' "$file" 2>/dev/null; then - continue - fi - - # Extract the currency date. TWO vocabularies are in the wild and both - # are honored: `freshness:` (the original) and `last-synced:` (used by - # QUICKCONTEXT and CONSUMES). Parsing only the first silently exempted - # the most-read doc in every rebar repo — rebar's own QUICKCONTEXT - # drifted 29 days while cold-start reported freshness all-green. - # See feedback/2026-08-02-freshness-check-parses-one-vocabulary-*.md. - # `freshness:` wins when a file carries both. - # - # `grep -oE` exits 1 when no marker is present (most files have none). - # Under `set -o pipefail` that aborts the whole pipeline, so disable - # pipefail just for this lookup. - set +o pipefail - marker="freshness" - date_str=$(grep -o 'freshness: [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}' "$file" 2>/dev/null | head -1 | sed 's/freshness: //') - if [ -z "$date_str" ]; then - marker="last-synced" - date_str=$(grep -o 'last-synced: [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}' "$file" 2>/dev/null | head -1 | sed 's/last-synced: //') - fi - set -o pipefail - - if [ -z "$date_str" ]; then - unmarked=$((unmarked + 1)) - continue - fi - - # Skip placeholder dates - case "$date_str" in - YYYY-MM-DD|0000-00-00) continue ;; - esac - - total=$((total + 1)) - - # Calculate age (portable: works on macOS and Linux) - if date --version >/dev/null 2>&1; then - # GNU date (Linux) - doc_epoch=$(date -d "$date_str" +%s 2>/dev/null || echo 0) - else - # BSD date (macOS) - doc_epoch=$(date -j -f "%Y-%m-%d" "$date_str" +%s 2>/dev/null || echo 0) - fi - - [ "$doc_epoch" -eq 0 ] && continue - - age_days=$(( (TODAY - doc_epoch) / 86400 )) - - if [ "$age_days" -gt "$MAX_AGE_DAYS" ]; then - echo "STALE ($age_days days): $file — $marker: $date_str" - stale=$((stale + 1)) - fi -done < <(find . -name "*.md" -not -path "./.git/*" -not -path "*/node_modules/*" -type f) - -echo "" -echo "Checked $total docs with currency markers (freshness:/last-synced:), $stale stale (>${MAX_AGE_DAYS} days)." -echo "$unmarked .md file(s) carry no currency marker at all — unchecked, not verified." - -if [ "$stale" -gt 0 ]; then - echo "" - echo "Stale docs may contain outdated status claims." - echo "Review and update the freshness date after verifying content." - exit 1 -fi - -exit 0 diff --git a/templates/project-bootstrap/scripts/check-ground-truth.sh b/templates/project-bootstrap/scripts/check-ground-truth.sh deleted file mode 100755 index 78d4abb..0000000 --- a/templates/project-bootstrap/scripts/check-ground-truth.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# check-ground-truth.sh — Verify METRICS file matches codebase reality. -# rebar-scripts: 2026.03.20 -# -# Computes project metrics from code and compares against claims in the -# METRICS file. Catches "silent success" drift where everything works but -# documented numbers describe a different reality. -# -# CUSTOMIZATION REQUIRED: Define your project's metrics in compute_metrics(). -# -# Usage: ./scripts/check-ground-truth.sh -# Exit: 0 = all claims match, 1 = drift detected - -set -euo pipefail - -# Tier gate: ground truth is Tier 3 only (skip for Tier 1-2) -SCRIPT_DIR_GT="$(cd "$(dirname "$0")" && pwd)" -[ -f "$SCRIPT_DIR_GT/_rebar-config.sh" ] && source "$SCRIPT_DIR_GT/_rebar-config.sh" && _rebar_skip 3 && exit 0 - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# METRICS or METRICS.md — the template ships .md and `rebar audit` -# accepts both, so this checker must too. -METRICS_FILE="$REPO_ROOT/METRICS" -[ ! -f "$METRICS_FILE" ] && [ -f "$REPO_ROOT/METRICS.md" ] && METRICS_FILE="$REPO_ROOT/METRICS.md" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' -exit_code=0 - -# ── CUSTOMIZE: Define your project's metrics ── -# -# Each metric is a key=value pair echoed to stdout. -# Keys must match entries in the METRICS file. -# -# By convention, these locations are reliable and countable: -# tests/ — all test files live here -# architecture/ — all contracts live here -# src/ — source code with CONTRACT: headers -# -compute_metrics() { - # Examples (uncomment and adjust for your project): - # - # echo "unit_test_files=$(find tests/ -name '*.test.ts' -o -name '*.test.tsx' 2>/dev/null | wc -l | tr -d ' ')" - # echo "e2e_spec_files=$(find tests/e2e/ -name '*.spec.ts' 2>/dev/null | wc -l | tr -d ' ')" - # echo "contracts=$(ls architecture/CONTRACT-*.md 2>/dev/null | wc -l | tr -d ' ')" - # echo "api_route_modules=$(find src/routes/ -name '*.ts' -not -name 'index.ts' -not -name '*.test.ts' 2>/dev/null | wc -l | tr -d ' ')" - - : # no-op — remove this line when adding metrics -} - -# ── Verification engine (do not modify below this line) ── - -verify() { - if [ ! -f "$METRICS_FILE" ]; then - echo -e "${YELLOW}SKIP${NC}: No METRICS file found" - echo " Create a METRICS file with key = value pairs." - echo " See scripts/check-ground-truth.sh for examples." - return 0 - fi - - local computed - computed=$(compute_metrics) - - if [ -z "$computed" ]; then - echo "No metrics defined. Customize compute_metrics() in this script." - return 0 - fi - - while IFS='=' read -r key value; do - # Skip empty lines and comments - [ -z "$key" ] && continue - echo "$key" | grep -q '^[[:space:]]*#' && continue - - key=$(echo "$key" | xargs) - value=$(echo "$value" | xargs) - - # Look up key in METRICS file - local documented - documented=$(grep "^[[:space:]]*${key}[[:space:]]*=" "$METRICS_FILE" 2>/dev/null \ - | head -1 | cut -d'=' -f2 | xargs) || true - - if [ -z "$documented" ]; then - echo -e "${YELLOW}NEW${NC}: $key = $value (not in METRICS file)" - elif [ "$value" = "$documented" ]; then - echo -e "${GREEN}OK${NC}: $key = $value" - else - echo -e "${RED}DRIFT${NC}: $key — METRICS says $documented, code says $value" - exit_code=1 - fi - done <<< "$computed" -} - -echo "=== Ground Truth Verification ===" -echo "" -verify -echo "" - -if [ $exit_code -eq 0 ]; then - echo -e "${GREEN}All documented metrics match codebase reality${NC}" -else - echo -e "${RED}Metric drift detected — update METRICS to match reality${NC}" -fi - -exit $exit_code diff --git a/templates/project-bootstrap/scripts/check-jtbd-presence.sh b/templates/project-bootstrap/scripts/check-jtbd-presence.sh deleted file mode 100755 index 0425416..0000000 --- a/templates/project-bootstrap/scripts/check-jtbd-presence.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# check-jtbd-presence.sh — Enforce JTBD framing sections on contracts -# rebar-scripts: 2026.07.04 -# -# Usage: ./scripts/check-jtbd-presence.sh -# -# Every latest-version contract in architecture/ MUST have non-empty -# "## Why this exists", "## Who needs this", and "## Scenarios" sections — -# the JTBD framing required by CONTRACT-TEMPLATE.md. Catches the -# "interface description without motivation" anti-pattern: a contract that -# reads like a header file with no callers. -# -# Skipped: -# - Template / registry / companion files (CONTRACT-TEMPLATE.md, -# CONTRACT-SEAM-TEMPLATE.md, CONTRACT-REGISTRY*, CONTRACT-GAPS.md, -# *.impl.md) -# - Older versions of a contract ID — only the latest version on disk is -# held to the requirement -# - Contracts marked "SUPERSEDED BY:" — covers the window where the newer -# version lives on another branch or lands in a concurrent commit -# - SKIP_JTBD=1 skips the whole check -# -# Tier behavior: below Tier 2 = skip; Tier 2 = warn only (exit 0); -# Tier 3 = blocking (exit 1 on missing sections). -# -# Source: feedback/processed/2026-04-24-contract-discipline-and-jtbd-framing.md §E -# See: practices/spike-first-contracts.md for the JTBD framing rationale -# -# Bash 3.2 compatible (macOS default). -# -# Exit code: 0 = all present (or advisory tier), 1 = missing sections at Tier 3 - -set -uo pipefail - -# Honor the explicit skip flag (house style: ci-check.sh SKIP_* family) -if [ "${SKIP_JTBD:-0}" = "1" ]; then - echo "SKIP: check-jtbd-presence (SKIP_JTBD=1)" - exit 0 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ARCH_DIR="$PROJECT_ROOT/architecture" - -# Tier gate: JTBD presence is Tier 2+ (warning) and blocks at Tier 3 -TIER=3 -if [ -f "$SCRIPT_DIR/_rebar-config.sh" ]; then - source "$SCRIPT_DIR/_rebar-config.sh" - _rebar_skip 2 && exit 0 - TIER="$(_rebar_tier)" -fi - -if [ ! -d "$ARCH_DIR" ]; then - echo "SKIP: no architecture/ directory found." - exit 0 -fi - -index_tmp="$(mktemp)" -latest_tmp="$(mktemp)" -trap 'rm -f "$index_tmp" "$latest_tmp"' EXIT - -# ─── Index contracts: ID, major, minor, filename ──────────────────────────── - -for contract in "$ARCH_DIR"/CONTRACT-*.md; do - [ -f "$contract" ] || continue - base="$(basename "$contract")" - - # Skip templates, registry files, gap tracker, and companions - case "$base" in - CONTRACT-TEMPLATE.md|CONTRACT-SEAM-TEMPLATE.md|CONTRACT-REGISTRY*|CONTRACT-GAPS.md) - continue ;; - *.impl.md) - continue ;; - esac - - # Parse ID + version from filename (same logic as compute-registry.sh) - stem="${base#CONTRACT-}" - stem="${stem%.md}" - if [[ "$stem" =~ ^(.+)\.([0-9]+)\.([0-9]+)$ ]]; then - id="${BASH_REMATCH[1]}"; major="${BASH_REMATCH[2]}"; minor="${BASH_REMATCH[3]}" - elif [[ "$stem" =~ ^(.+)\.([0-9]+)$ ]]; then - id="${BASH_REMATCH[1]}"; major="${BASH_REMATCH[2]}"; minor=0 - else - id="$stem"; major=1; minor=0 - fi - - printf '%s\t%s\t%s\t%s\n' "$id" "$major" "$minor" "$base" >> "$index_tmp" -done - -if [ ! -s "$index_tmp" ]; then - echo "OK: no contracts found in architecture/ — nothing to check." - exit 0 -fi - -# Keep only the latest version per contract ID (sort by ID, then version -# numerically; last row per ID wins). awk arrays keep this bash 3.2 safe — -# no associative arrays in the shell itself. -sort -t"$(printf '\t')" -k1,1 -k2,2n -k3,3n "$index_tmp" \ - | awk -F'\t' '{ latest[$1] = $4 } END { for (id in latest) print latest[id] }' \ - | sort > "$latest_tmp" - -# ─── Section presence ──────────────────────────────────────────────────────── - -# section_present -# Returns 0 when the section exists AND has non-comment, non-whitespace -# content before the next same-or-higher-level heading. Subsections -# (### Scenario 1 — ...) belong to the section and count as content. -# HTML comments (the template's inline guidance) don't count as content. -section_present() { - awk -v pat="$2" ' - BEGIN { insect = 0; incom = 0; content = 0 } - insect == 0 && $0 ~ pat { insect = 1; next } - insect == 1 && /^##?[[:space:]]/ { exit } - insect == 1 { - line = $0 - if (incom) { - if (sub(/^.*-->/, "", line)) incom = 0 - else next - } - gsub(//, "", line) - if (index(line, " - -Auto-generated by `scripts/check-version-bump.sh` (post-commit hook). -Owner runs `scripts/flush-notifications.sh` to deliver pending entries -via the `ask__featurerequest` gate. - -Each entry: contract id, old → new version, detected timestamp, -commit, status (pending | sent | dropped). `flush-notifications.sh` -auto-classifies severity (breaking | additive | patch) from the -semver delta unless --severity overrides. - -EOF -fi - -now=$(date -u +%Y-%m-%dT%H:%M:%SZ) -short_sha=$(git rev-parse --short HEAD) -appended=0 - -# IFS pipe-separated, parsed on the way in. -echo "$renames" | while IFS='|' read -r old new; do - old_iv=$(parse_contract_path "$old") - new_iv=$(parse_contract_path "$new") - old_id="${old_iv%%|*}" - old_ver="${old_iv#*|}" - new_id="${new_iv%%|*}" - new_ver="${new_iv#*|}" - # Skip if id changed (rename to a different contract, not a version bump) - [ "$old_id" = "$new_id" ] || continue - [ -z "$new_id" ] && continue - - cat >> "$OUTBOX" </dev/null | tr -d ' \n' || echo 0) -if [ "$pending" != "0" ] && [ "$pending" != "" ]; then - echo "rebar: contract version bump recorded → outbox has $pending pending notification(s); flush with scripts/flush-notifications.sh" >&2 -fi diff --git a/templates/project-bootstrap/scripts/ci-check.sh b/templates/project-bootstrap/scripts/ci-check.sh index ad9a6c0..32c50a8 100755 --- a/templates/project-bootstrap/scripts/ci-check.sh +++ b/templates/project-bootstrap/scripts/ci-check.sh @@ -1,110 +1,30 @@ #!/usr/bin/env bash -# ci-check.sh — Atomic CI entrypoint that runs all contract/doc checks -# rebar-scripts: 2026.03.20 +# ci-check.sh — CI entrypoint: delegates to the rebar CLI. # -# Usage: ./scripts/ci-check.sh [--strict] +# Usage: +# ./scripts/ci-check.sh +# ./scripts/ci-check.sh --no-fail # report but always exit 0 # -# Runs each check script and reports a summary. In strict mode (default), -# any failure fails the CI job. In non-strict mode, failures are warnings. +# The rebar CLI runs the full enforcement and audit suite. Logic lives in +# the rebar install — this script is intentionally a thin wrapper so +# adopting repos do not carry a copy of rebar's internals. # -# Individual checks can be skipped with environment variables: -# SKIP_CONTRACT_HEADERS=1 — skip contract header check -# SKIP_CONTRACT_REFS=1 — skip contract reference check -# SKIP_TODOS=1 — skip TODO tracking check -# SKIP_FRESHNESS=1 — skip freshness check -# SKIP_REGISTRY=1 — skip registry consistency check -# SKIP_JTBD=1 — skip JTBD-presence contract check -# SKIP_PREFIX_UNIQUENESS=1 — skip contract prefix-number uniqueness check -# SKIP_GROUND_TRUTH=1 — skip ground truth metric verification -# SKIP_COMPLIANCE=1 — skip rebar compliance check -# SKIP_STEWARD=1 — skip steward health scan -# SKIP_DOC_REFS=1 — skip cross-doc reference check -# SKIP_DECAY_PATTERNS=1 — skip soft-hardening decay pattern check -# SKIP_BOOTSTRAP_SYNC=1 — skip templates/project-bootstrap/scripts drift check -# SKIP_FIX_COMMIT=1 — skip Gate G (HEAD's fix:/regression: must have Reproduced on:) -# SKIP_BYPASS_FLAGS=1 — skip Gate I (HEAD's bypass mentions must have Bypass tickets:) -# -# Exit code: 0 = all pass, 1 = failures in strict mode - -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -STRICT="${1:---strict}" - -passed=0 -failed=0 -skipped=0 -results=() - -run_check() { - local name="$1" - local skip_var="$2" - local script="$3" - shift 3 - - # Check skip flag - if [ "${!skip_var:-0}" = "1" ]; then - results+=("SKIP $name") - skipped=$((skipped + 1)) - return - fi +# Suppress individual checks via SKIP_* env vars understood by rebar audit: +# SKIP_CONTRACT_HEADERS=1 SKIP_TODOS=1 SKIP_FRESHNESS=1 etc. - # Check script exists - if [ ! -x "$script" ]; then - results+=("SKIP $name (script not found or not executable)") - skipped=$((skipped + 1)) - return - fi +set -euo pipefail - echo "" - echo "━━━ $name ━━━" - if "$script" "$@"; then - results+=("PASS $name") - passed=$((passed + 1)) - else - results+=("FAIL $name") - failed=$((failed + 1)) - fi -} +NO_FAIL=0 +[[ "${1:-}" == "--no-fail" ]] && NO_FAIL=1 -echo "Running contract and documentation checks..." - -run_check "Contract Headers" SKIP_CONTRACT_HEADERS "$SCRIPT_DIR/check-contract-headers.sh" -run_check "Contract References" SKIP_CONTRACT_REFS "$SCRIPT_DIR/check-contract-refs.sh" -run_check "Doc References" SKIP_DOC_REFS "$SCRIPT_DIR/check-doc-refs.sh" -run_check "TODO Tracking" SKIP_TODOS "$SCRIPT_DIR/check-todos.sh" -run_check "Doc Freshness" SKIP_FRESHNESS "$SCRIPT_DIR/check-freshness.sh" -run_check "Registry Consistency" SKIP_REGISTRY "$SCRIPT_DIR/compute-registry.sh" --check -run_check "JTBD Presence" SKIP_JTBD "$SCRIPT_DIR/check-jtbd-presence.sh" -run_check "Prefix Uniqueness" SKIP_PREFIX_UNIQUENESS "$SCRIPT_DIR/check-prefix-uniqueness.sh" -run_check "Ground Truth" SKIP_GROUND_TRUTH "$SCRIPT_DIR/check-ground-truth.sh" -run_check "Rebar Compliance" SKIP_COMPLIANCE "$SCRIPT_DIR/check-compliance.sh" -run_check "Decay Patterns" SKIP_DECAY_PATTERNS "$SCRIPT_DIR/check-decay-patterns.sh" -run_check "Bootstrap Sync" SKIP_BOOTSTRAP_SYNC "$SCRIPT_DIR/sync-bootstrap.sh" --check -run_check "Fix Commit Gate" SKIP_FIX_COMMIT "$SCRIPT_DIR/check-fix-commit.sh" -run_check "Bypass Flags Gate" SKIP_BYPASS_FLAGS "$SCRIPT_DIR/check-bypass-flags.sh" -run_check "Steward" SKIP_STEWARD "$SCRIPT_DIR/steward.sh" - -# NOTE: Tag-to-CI coverage check (Node.js, project-specific) lives at -# templates/scripts/check-tag-ci-coverage.mjs. Adopters with Playwright -# or @-tagged spec files should copy it into their own scripts/ and wire -# it into their CI workflow directly, not via this universal ci-check. - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SUMMARY" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -for result in "${results[@]}"; do - echo " $result" -done -echo "" -echo " $passed passed, $failed failed, $skipped skipped" - -if [ "$failed" -gt 0 ] && [ "$STRICT" = "--strict" ]; then - echo "" - echo "CI check failed. Fix the issues above or skip individual checks" - echo "with environment variables (e.g., SKIP_FRESHNESS=1)." +if ! command -v rebar &>/dev/null; then + echo "ci-check: rebar not found in PATH" >&2 + echo "Install rebar: https://github.com/willackerly/rebar#installation" >&2 exit 1 fi -exit 0 +if [[ "$NO_FAIL" -eq 1 ]]; then + rebar audit || true +else + exec rebar audit +fi diff --git a/templates/project-bootstrap/scripts/compute-registry.sh b/templates/project-bootstrap/scripts/compute-registry.sh deleted file mode 100755 index a7d5d25..0000000 --- a/templates/project-bootstrap/scripts/compute-registry.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env bash -# compute-registry.sh — Generate CONTRACT-REGISTRY.md from contract files on disk -# rebar-scripts: 2026.03.20 -# -# Usage: -# ./scripts/compute-registry.sh Generate architecture/CONTRACT-REGISTRY.md -# ./scripts/compute-registry.sh --check Exit 0 if current, 1 if stale -# ./scripts/compute-registry.sh --stdout Print to stdout instead of writing file -# -# The contract filesystem IS the registry. This script just renders it. -# -# Exit code: 0 = success/current, 1 = stale (--check mode) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ARCH_DIR="$PROJECT_ROOT/architecture" -REGISTRY="$ARCH_DIR/CONTRACT-REGISTRY.md" -MODE="${1:-generate}" - -# ─── Helpers ──────────────────────────────────────────────────────────────── - -# Parse contract filename → ID + version (reused logic from steward.sh) -parse_contract_file() { - local filename - filename="$(basename "$1")" - - local stem="${filename#CONTRACT-}" - stem="${stem%.md}" - - if [[ "$stem" =~ ^(.+)\.([0-9]+)\.([0-9]+)$ ]]; then - CONTRACT_ID="${BASH_REMATCH[1]}" - CONTRACT_VERSION="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" - elif [[ "$stem" =~ ^(.+)\.([0-9]+)$ ]]; then - CONTRACT_ID="${BASH_REMATCH[1]}" - CONTRACT_VERSION="${BASH_REMATCH[2]}.0" - else - CONTRACT_ID="$stem" - CONTRACT_VERSION="1.0" - fi -} - -# Extract status from contract file (looks for **Status:** or Status: line) -extract_status() { - local file="$1" - local status - status=$(grep -i '^\*\*Status:\*\*\|^Status:' "$file" 2>/dev/null | head -1 | sed 's/.*:\s*//' | tr -d '*' | xargs) - echo "${status:-draft}" -} - -# Extract purpose (first non-empty line after ## Purpose) -extract_purpose() { - local file="$1" - awk '/^## Purpose/{found=1; next} found && /^[^#]/ && NF{print; exit}' "$file" 2>/dev/null | head -c 80 -} - -# Classify by prefix -classify_prefix() { - local id="$1" - case "$id" in - S*) echo "Services" ;; - C*) echo "Components" ;; - I*) echo "Interfaces" ;; - P*) echo "Protocols" ;; - *) echo "Other" ;; - esac -} - -# Count implementing files for a contract. -# `grep` returns 1 when there are no matches; under `set -o pipefail` that -# would abort the whole script for any orphan contract. The greps in this -# pipeline legitimately return 1 on empty input, so we run with pipefail -# temporarily disabled. -# -# Two passes, deduplicated by file path: -# 1. Source files matching the standard extension whitelist -# 2. Files anywhere under `bin/` (CLI entry points often have no extension — -# `bin/ask`, `bin/ask-mcp-server`, `bin/rebar`) -count_implementations() { - local id="$1" - set +o pipefail - { - grep -rln "CONTRACT:${id}" "$PROJECT_ROOT" \ - --include='*.go' --include='*.ts' --include='*.tsx' --include='*.js' \ - --include='*.py' --include='*.rs' --include='*.java' --include='*.rb' \ - --include='*.jsx' --include='*.sh' 2>/dev/null - if [ -d "$PROJECT_ROOT/bin" ]; then - grep -rln "CONTRACT:${id}" "$PROJECT_ROOT/bin" 2>/dev/null - fi - } \ - | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/" \ - | sort -u \ - | wc -l | tr -d ' ' - set -o pipefail -} - -# ─── Collect Contracts ────────────────────────────────────────────────────── - -declare -a services=() components=() interfaces=() protocols=() other=() - -for contract in "$ARCH_DIR"/CONTRACT-*.md; do - [ ! -f "$contract" ] && continue - - basename=$(basename "$contract") - - # Skip non-contract files - case "$basename" in - CONTRACT-TEMPLATE.md|CONTRACT-SEAM-TEMPLATE.md|CONTRACT-REGISTRY.md|CONTRACT-REGISTRY.template.md|CONTRACT-GAPS.md) - continue ;; - *.impl.md) - continue ;; - esac - - parse_contract_file "$contract" - local_status=$(extract_status "$contract") - local_purpose=$(extract_purpose "$contract") - impl_count=$(count_implementations "$CONTRACT_ID") - - entry="| $CONTRACT_ID | $CONTRACT_VERSION | $local_status | $impl_count | ${local_purpose:-—} |" - - category=$(classify_prefix "$CONTRACT_ID") - case "$category" in - Services) services+=("$entry") ;; - Components) components+=("$entry") ;; - Interfaces) interfaces+=("$entry") ;; - Protocols) protocols+=("$entry") ;; - *) other+=("$entry") ;; - esac -done - -# ─── Generate Output ──────────────────────────────────────────────────────── - -GENERATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - -generate_registry() { - cat <
- - - -HEADER - - # bash 3.2-compatible: pass array entries through a function instead of - # using `local -n` namerefs (bash 4.3+). macOS ships bash 3.2 by default. - emit_category() { - local category="$1"; shift - [ $# -eq 0 ] && return - echo "## $category" - echo "" - echo "| ID | Version | Status | Impl Files | Purpose |" - echo "|----|---------|--------|------------|---------|" - local entry - for entry in "$@"; do - echo "$entry" - done - echo "" - } - - emit_category "Services" ${services[@]+"${services[@]}"} - emit_category "Components" ${components[@]+"${components[@]}"} - emit_category "Interfaces" ${interfaces[@]+"${interfaces[@]}"} - emit_category "Protocols" ${protocols[@]+"${protocols[@]}"} - emit_category "Other" ${other[@]+"${other[@]}"} - - # Contract files section (for tooling compatibility) - echo "## Contract Files" - echo "" - echo "" - for contract in "$ARCH_DIR"/CONTRACT-*.md; do - [ ! -f "$contract" ] && continue - basename=$(basename "$contract") - case "$basename" in - CONTRACT-TEMPLATE.md|CONTRACT-SEAM-TEMPLATE.md|CONTRACT-REGISTRY.md|CONTRACT-REGISTRY.template.md|CONTRACT-GAPS.md) - continue ;; - *.impl.md) - continue ;; - esac - echo "- $basename" - done - echo "" - - # ─── Contract Health: zombie + shadow detection ────────────────────────── - # zombie — contract file exists but no CONTRACT: headers in source - # (deletion candidate unless tracked in TODO.md) - # shadow — CONTRACT: reference in source but no architecture/.md - # file (typo, deleted contract, or not-yet-written spec) - - local zombies=() - local known_ids=" " # space-padded list for easy substring matching - for contract in "$ARCH_DIR"/CONTRACT-*.md; do - [ ! -f "$contract" ] && continue - basename=$(basename "$contract") - case "$basename" in - CONTRACT-TEMPLATE.md|CONTRACT-SEAM-TEMPLATE.md|CONTRACT-REGISTRY.md|CONTRACT-REGISTRY.template.md|CONTRACT-GAPS.md) - continue ;; - *.impl.md) - continue ;; - esac - - parse_contract_file "$contract" - known_ids="$known_ids$CONTRACT_ID " - - local count - count=$(count_implementations "$CONTRACT_ID") - if [ "$count" -eq 0 ]; then - if [ -f "$PROJECT_ROOT/TODO.md" ] && grep -q "$CONTRACT_ID" "$PROJECT_ROOT/TODO.md" 2>/dev/null; then - zombies+=("- **$CONTRACT_ID** — tracked in TODO.md") - else - zombies+=("- **$CONTRACT_ID** — ⚠ untracked, 0 implementing files") - fi - fi - done - - # Shadow detection: every CONTRACT:. reference in source, - # filtered to IDs not in known_ids. Same extension whitelist + bin/ - # second-pass as count_implementations() so we don't miss CLI scripts. - local shadows_tmp - shadows_tmp="$(mktemp)" - set +o pipefail - { - grep -rEhno 'CONTRACT:[A-Za-z0-9_-]+\.[0-9]+\.[0-9]+' "$PROJECT_ROOT" \ - --include='*.go' --include='*.ts' --include='*.tsx' --include='*.js' \ - --include='*.py' --include='*.rs' --include='*.java' --include='*.rb' \ - --include='*.jsx' --include='*.sh' 2>/dev/null - if [ -d "$PROJECT_ROOT/bin" ]; then - grep -rEhno 'CONTRACT:[A-Za-z0-9_-]+\.[0-9]+\.[0-9]+' "$PROJECT_ROOT/bin" 2>/dev/null - fi - } \ - | grep -v "node_modules\|vendor\|dist\|\.git\|architecture/" \ - | sed -E 's/^[^:]*:[^:]*://; s/^CONTRACT://; s/\.[0-9]+\.[0-9]+$//' \ - | sort -u > "$shadows_tmp" - set -o pipefail - - local shadows=() - while IFS= read -r ref_id; do - [ -z "$ref_id" ] && continue - case "$known_ids" in - *" $ref_id "*) ;; # known — not a shadow - *) shadows+=("- **$ref_id** — referenced in source, no architecture/CONTRACT-${ref_id}.*.md found") ;; - esac - done < "$shadows_tmp" - rm -f "$shadows_tmp" - - if [ ${#zombies[@]} -gt 0 ] || [ ${#shadows[@]} -gt 0 ]; then - echo "## Contract Health" - echo "" - - if [ ${#zombies[@]} -gt 0 ]; then - echo "### Zombies — contract files with no implementing code" - echo "" - echo "(Deletion candidates unless intentionally pre-impl. Add a TODO.md entry" - echo "to suppress this warning while implementation is in flight.)" - echo "" - for z in "${zombies[@]}"; do - echo "$z" - done - echo "" - fi - - if [ ${#shadows[@]} -gt 0 ]; then - echo "### Shadow refs — code references to nonexistent contracts" - echo "" - echo "(Either write the missing contract file or fix the typo in source.)" - echo "" - for s in "${shadows[@]}"; do - echo "$s" - done - echo "" - fi - fi -} - -# ─── Execute Mode ─────────────────────────────────────────────────────────── - -case "$MODE" in - --check) - if [ ! -f "$REGISTRY" ]; then - echo "FAIL: $REGISTRY does not exist. Run scripts/compute-registry.sh to generate." - exit 1 - fi - - # Generate to temp and compare - tmpfile=$(mktemp) - generate_registry > "$tmpfile" - - # Compare ignoring the timestamp line - if diff <(grep -v "^" - echo "" - echo "" - echo "## Summary" - echo "" - echo "| Metric | Value |" - echo "|--------|-------|" - echo "| Contracts | $total total ($draft draft, $active active, $testing testing, $impl_present impl-present) |" - echo "| Open Discoveries | $open_disc |" - echo "| Enforcement | $enf_pass/$enf_total passing |" - echo "" - - # Contract Status - echo "## Contract Status" - echo "" - echo "| Contract | Version | Lifecycle | Spec Gate | Impl Files | Test Files | Discoveries |" - echo "|----------|---------|-----------|-----------|------------|------------|-------------|" - - if [ "$total" -eq 0 ]; then - echo "| _(none)_ | | | | | | |" - else - echo "$report_json" | jq -r '.contracts[] | "| \(.contract_id) | \(.version) | \(.lifecycle) | \(.spec_gate.completeness) | \(.impl_gate.implementing_count) | \(.impl_gate.test_count) | \(.discoveries | length) |"' - fi - - echo "" - - # Action Items - echo "## Action Items" - echo "" - - echo "### Architect" - local arch_count - arch_count="$(echo "$report_json" | jq '.action_items.architect | length')" - if [ "$arch_count" -eq 0 ]; then - echo "- _(no items)_" - else - echo "$report_json" | jq -r '.action_items.architect[] | "- \(.)"' - fi - echo "" - - echo "### Engineering Lead" - local eng_count - eng_count="$(echo "$report_json" | jq '.action_items.englead | length')" - if [ "$eng_count" -eq 0 ]; then - echo "- _(no items)_" - else - echo "$report_json" | jq -r '.action_items.englead[] | "- \(.)"' - fi - echo "" - - echo "### Product" - local prod_count - prod_count="$(echo "$report_json" | jq '.action_items.product | length')" - if [ "$prod_count" -eq 0 ]; then - echo "- _(no items)_" - else - echo "$report_json" | jq -r '.action_items.product[] | "- \(.)"' - fi - echo "" - - echo "### Developer" - local dev_count - dev_count="$(echo "$report_json" | jq '.action_items.dev | length')" - if [ "$dev_count" -eq 0 ]; then - echo "- _(no items)_" - else - echo "$report_json" | jq -r '.action_items.dev[] | "- \(.)"' - fi - echo "" - - # Open Discoveries - echo "## Open Discoveries" - echo "" - echo "_(See TODO.md Discoveries section for details)_" - echo "" - echo "| Type | Contract | Description |" - echo "|------|----------|-------------|" - - local disc_count - disc_count="$(echo "$report_json" | jq '[.contracts[].discoveries[]] | length')" - if [ "$disc_count" -eq 0 ]; then - echo "| _(none)_ | | |" - else - echo "$report_json" | jq -r '.contracts[] as $c | $c.discoveries[] | "| \(.type) | \($c.contract_id) | \(.description) |"' - fi - - echo "" - - # Enforcement Results - echo "## Enforcement Results" - echo "" - echo "| Check | Result |" - echo "|-------|--------|" - - local check_names=( - "contract_headers:Contract Headers" - "contract_refs:Contract References" - "todo_tracking:TODO Tracking" - "doc_freshness:Doc Freshness" - "registry:Registry Consistency" - "ground_truth:Ground Truth" - "compliance:Rebar Compliance" - ) - - for entry in "${check_names[@]}"; do - local key="${entry%%:*}" - local label="${entry##*:}" - local result - result="$(echo "$report_json" | jq -r ".enforcement.${key} // \"_(not run)_\"")" - echo "| $label | $result |" - done - - echo "" - echo "---" - echo "" - echo "_Generated at ${GENERATED_AT} by \`scripts/steward.sh\`_" - } > "$output" -} - -# ─── Main ─────────────────────────────────────────────────────────────────── - -# Globals set by run_enforcement / generate_action_items -ENFORCEMENT_JSON='{}' -ENFORCEMENT_PASSING=0 -ENFORCEMENT_TOTAL=0 -ACTION_ARCHITECT='[]' -ACTION_ENGLEAD='[]' -ACTION_PRODUCT='[]' -ACTION_DEV='[]' - -main() { - local mode="${1:-full}" - local check_id="${2:-}" - - # Single contract check - if [ "$mode" = "--check" ]; then - if [ -z "$check_id" ]; then - echo "Usage: steward.sh --check " >&2 - exit 1 - fi - # Find contract file - local contract_file - contract_file="$(ls "$ARCH_DIR"/CONTRACT-"${check_id}"*.md 2>/dev/null | head -1 || true)" - if [ -z "$contract_file" ]; then - echo "Contract not found: $check_id" >&2 - echo "Valid IDs:" >&2 - ls "$ARCH_DIR"/CONTRACT-*.md 2>/dev/null \ - | sed -e 's|.*/CONTRACT-||' -e 's|\.[0-9][0-9.]*\.md$||' \ - | grep -v -E 'TEMPLATE|REGISTRY|GAPS|\.impl$' | sort -u | sed 's/^/ /' >&2 - exit 1 - fi - scan_contract "$contract_file" - cat "$STATE_DIR/${check_id}."*.json 2>/dev/null | jq . - exit 0 - fi - - # Full scan: collect all contracts - local all_contracts='[]' - local contract_files=() - - while IFS= read -r f; do - contract_files+=("$f") - done < <(ls "$ARCH_DIR"/CONTRACT-*.md 2>/dev/null | grep -v TEMPLATE | grep -v REGISTRY || true) - - for contract_file in "${contract_files[@]+"${contract_files[@]}"}"; do - local state_file - state_file="$(scan_contract "$contract_file")" - local contract_json - contract_json="$(cat "$state_file")" - all_contracts="$(echo "$all_contracts" | jq --argjson c "$contract_json" '. + [$c]')" - done - - # Run enforcement checks - run_enforcement - - # Generate action items - generate_action_items "$all_contracts" - - # Build aggregate report - local report - report="$(build_aggregate "$all_contracts")" - - # Write aggregate JSON - echo "$report" | jq . > "$STATE_DIR/steward-report.json" - - case "$mode" in - --json) - echo "$report" | jq . - ;; - --summary) - local total draft active testing impl_present open_disc enf_pass enf_total - total="$(echo "$report" | jq '.summary.contracts.total')" - draft="$(echo "$report" | jq '.summary.contracts.draft')" - active="$(echo "$report" | jq '.summary.contracts.active')" - testing="$(echo "$report" | jq '.summary.contracts.testing')" - impl_present="$(echo "$report" | jq '.summary.contracts.impl_present')" - open_disc="$(echo "$report" | jq '.summary.open_discoveries')" - enf_pass="$(echo "$report" | jq '.summary.enforcement.passing')" - enf_total="$(echo "$report" | jq '.summary.enforcement.total')" - echo "Steward: ${total} contracts (${draft}d/${active}a/${testing}t/${impl_present}ip), ${open_disc} discoveries, ${enf_pass}/${enf_total} enforcement passing" - ;; - full|*) - # Generate markdown report - generate_markdown "$report" - echo "Steward scan complete." - echo " JSON: $STATE_DIR/steward-report.json" - echo " Markdown: $PROJECT_ROOT/STEWARD_REPORT.md" - echo "" - # Print summary line - local total - total="$(echo "$report" | jq '.summary.contracts.total')" - local enf_pass enf_total - enf_pass="$(echo "$report" | jq '.summary.enforcement.passing')" - enf_total="$(echo "$report" | jq '.summary.enforcement.total')" - local open_disc - open_disc="$(echo "$report" | jq '.summary.open_discoveries')" - echo " ${total} contracts, ${open_disc} discoveries, ${enf_pass}/${enf_total} enforcement passing" - ;; - esac -} - -main "$@"