|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# ci-local.sh — run the SAME blocking checks CI runs, locally, in one command. |
| 4 | +# |
| 5 | +# WHY THIS EXISTS |
| 6 | +# CI (.github/workflows/ci.yml) is split into ~11 parallel jobs so it stays |
| 7 | +# fast on GitHub's runners. That parallelism is good for CI but bad for a |
| 8 | +# human: before a push there was no single command that reproduced every |
| 9 | +# blocking gate, so a "green locally" push could still fail CI on a check |
| 10 | +# the dev never ran (real example: 0a7a8aa passed a local `cargo test` but |
| 11 | +# failed CI on fmt + clippy(type_complexity) + doc-truth, taking three |
| 12 | +# pushes — 0a7a8aa -> b1fd2a6 -> 903d3ef — to go green). This script is the |
| 13 | +# fix: ONE command, run every gate, report ALL failures at once instead of |
| 14 | +# one-per-push. |
| 15 | +# |
| 16 | +# It is intentionally NOT wired into ci.yml. CI keeps its parallel-job |
| 17 | +# structure (different runners, feature sets, and rust-cache scopes per job); |
| 18 | +# forcing every job through one serial script would lose that. Instead this |
| 19 | +# script mirrors those jobs and the mapping is kept explicit below, so if a |
| 20 | +# CI job is added/changed, the drift is visible here in review. |
| 21 | +# |
| 22 | +# CI JOB MAPPING (ci.yml -> phase here) |
| 23 | +# verify (fmt/clippy/test/audit) ...... LINT + TEST (+ audit in --full) |
| 24 | +# embeddings .......................... LINT (clippy) + TEST_MATRIX |
| 25 | +# no-stack-graphs-formal .............. LINT (clippy + ts-unpinned) + TEST_MATRIX |
| 26 | +# all-languages ....................... LINT (clippy) + TEST_MATRIX |
| 27 | +# otel-http-features .................. LINT (clippy) + TEST_MATRIX (+ skew guard) |
| 28 | +# stack-graphs-corpus ................. TEST_MATRIX |
| 29 | +# status-drift ........................ DOCS |
| 30 | +# fitness-check ....................... HEAVY (--full) |
| 31 | +# txn-crash-injection ................. HEAVY (--full) |
| 32 | +# js-client-interop ................... HEAVY (--full, needs npm) |
| 33 | +# scip-nightly: check-b2-thresholds ... HEAVY (--full) |
| 34 | +# calm-guard-dogfood .................. NOT MIRRORED (continue-on-error shadow job) |
| 35 | +# |
| 36 | +# USAGE |
| 37 | +# scripts/ci-local.sh # default: LINT + DOCS + TEST (fast, offline) |
| 38 | +# scripts/ci-local.sh --lint # LINT + DOCS only (fastest pre-push gate) |
| 39 | +# scripts/ci-local.sh --full # everything, == full CI (slow; some steps need network/npm) |
| 40 | +# |
| 41 | +# Runs every selected check even if an earlier one fails (does NOT stop on |
| 42 | +# first error), then prints a summary and exits non-zero if any failed. |
| 43 | + |
| 44 | +set -uo pipefail |
| 45 | + |
| 46 | +cd "$(dirname "$0")/.." || exit 2 |
| 47 | +export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-always}" |
| 48 | + |
| 49 | +MODE="default" |
| 50 | +case "${1:-}" in |
| 51 | + --lint) MODE="lint" ;; |
| 52 | + --full) MODE="full" ;; |
| 53 | + ""|--default) MODE="default" ;; |
| 54 | + -h|--help) |
| 55 | + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' |
| 56 | + exit 0 ;; |
| 57 | + *) |
| 58 | + echo "unknown argument: $1 (use --lint | --default | --full | --help)" >&2 |
| 59 | + exit 2 ;; |
| 60 | +esac |
| 61 | + |
| 62 | +# ---- result collection -------------------------------------------------- |
| 63 | +PASS=(); FAIL=() |
| 64 | +run_check() { |
| 65 | + local label="$1"; shift |
| 66 | + printf '\n\033[1;34m==> %s\033[0m\n' "$label" |
| 67 | + printf ' $ %s\n' "$*" |
| 68 | + if "$@"; then |
| 69 | + PASS+=("$label") |
| 70 | + else |
| 71 | + FAIL+=("$label") |
| 72 | + printf '\033[1;31m FAILED: %s\033[0m\n' "$label" |
| 73 | + fi |
| 74 | +} |
| 75 | + |
| 76 | +# The 11-language + lsp feature string the all-languages CI job uses, kept in |
| 77 | +# one place so it can't drift between the clippy and test invocations. |
| 78 | +ALL_LANG_FEATURES="tier0-5,lang-kotlin,lang-swift,lang-scala,lang-dart,lang-lua,lang-elixir,lang-haskell,lang-ocaml,lang-zig,lang-powershell,lang-groovy,scip-overlay,lsp-overlay" |
| 79 | +NO_SG_FEATURES="embeddings,tier0-5,scip-overlay" |
| 80 | + |
| 81 | +# Verify tree-sitter core is not pulled in transitively by the no-default |
| 82 | +# build (mirrors ci.yml no-stack-graphs-formal's "unpinned" guard). |
| 83 | +check_tree_sitter_unpinned() { |
| 84 | + local leaked |
| 85 | + leaked=$(cargo tree -p calm-core --no-default-features --features "$NO_SG_FEATURES" 2>&1 | grep -ic "stack-graph" || true) |
| 86 | + if [ "$leaked" -ne 0 ]; then |
| 87 | + echo "stack-graph* leaked into the no-stack-graphs-formal build ($leaked line(s))" >&2 |
| 88 | + return 1 |
| 89 | + fi |
| 90 | + echo "ok: no stack-graph* in no-default build" |
| 91 | +} |
| 92 | + |
| 93 | +# Guard against opentelemetry core version skew (mirrors otel-http-features). |
| 94 | +check_otel_skew() { |
| 95 | + local versions |
| 96 | + versions=$(cargo metadata --format-version 1 --features otel 2>/dev/null \ |
| 97 | + | jq -r '.packages[] | select(.name == "opentelemetry") | .version' | sort -u) |
| 98 | + local n |
| 99 | + n=$(printf '%s\n' "$versions" | grep -c . || true) |
| 100 | + if [ "$n" -gt 1 ]; then |
| 101 | + echo "opentelemetry appears at >1 version (skew): $versions" >&2 |
| 102 | + return 1 |
| 103 | + fi |
| 104 | + echo "ok: opentelemetry single version: ${versions:-<none>}" |
| 105 | +} |
| 106 | + |
| 107 | +# ---- LINT (always) ------------------------------------------------------ |
| 108 | +run_check "fmt --check" cargo fmt --all -- --check |
| 109 | +run_check "clippy (workspace)" cargo clippy --workspace --all-targets -- -D warnings |
| 110 | +run_check "clippy (embeddings)" cargo clippy -p calm-core --all-targets --features embeddings -- -D warnings |
| 111 | +run_check "clippy (no-stack-graphs)" cargo clippy -p calm-core --all-targets --no-default-features --features "$NO_SG_FEATURES" -- -D warnings |
| 112 | +run_check "clippy (all-languages+lsp)" cargo clippy --workspace --all-targets --features "$ALL_LANG_FEATURES" -- -D warnings |
| 113 | +run_check "clippy (otel)" cargo clippy -p calm-cli --features otel -- -D warnings |
| 114 | +run_check "clippy (http)" cargo clippy -p calm-server -p calm-cli --features http -- -D warnings |
| 115 | +run_check "tree-sitter unpinned guard" check_tree_sitter_unpinned |
| 116 | + |
| 117 | +# ---- DOCS (always) ------------------------------------------------------ |
| 118 | +run_check "status.generated.md fresh" ./scripts/gen-status.sh --check |
| 119 | +run_check "hand-authored doc truth" ./scripts/check-doc-truth.sh |
| 120 | +run_check "claims registry consistent" ./scripts/check-claims-registry.sh |
| 121 | + |
| 122 | +# ---- TEST (default + full) --------------------------------------------- |
| 123 | +if [ "$MODE" != "lint" ]; then |
| 124 | + run_check "test (workspace)" cargo test --workspace |
| 125 | +fi |
| 126 | + |
| 127 | +# ---- TEST_MATRIX + HEAVY (full only) ----------------------------------- |
| 128 | +if [ "$MODE" = "full" ]; then |
| 129 | + run_check "test (embeddings)" cargo test -p calm-core --features embeddings |
| 130 | + run_check "test (no-stack-graphs)" cargo test -p calm-core --no-default-features --features "$NO_SG_FEATURES" |
| 131 | + run_check "test (all-languages+lsp)" cargo test --workspace --features "$ALL_LANG_FEATURES" |
| 132 | + run_check "test (otel)" cargo test -p calm-cli --features otel |
| 133 | + run_check "otel version-skew guard" check_otel_skew |
| 134 | + run_check "test (http)" cargo test -p calm-server -p calm-cli --features http |
| 135 | + run_check "stack-graphs corpus" cargo test --test parity_test test_formal_edges -- --nocapture |
| 136 | + run_check "b2 thresholds" ./scripts/check-b2-thresholds.sh |
| 137 | + run_check "adr staleness" ./scripts/check-adr-staleness.sh |
| 138 | + # Networked / heaviest — only in --full, and tolerated-missing where a tool |
| 139 | + # isn't installed rather than silently skipped. |
| 140 | + if command -v cargo-audit >/dev/null 2>&1; then |
| 141 | + run_check "cargo audit" cargo audit |
| 142 | + else |
| 143 | + echo "note: cargo-audit not installed; run 'cargo install cargo-audit --locked' to include it" >&2 |
| 144 | + fi |
| 145 | + run_check "fitness-check (build+index)" bash -c ' |
| 146 | + cargo build --bin calm && |
| 147 | + ./target/debug/calm index --project-root . && |
| 148 | + ./target/debug/calm fitness-check --project-root . --config thresholds.toml' |
| 149 | + run_check "txn crash injection" cargo test -p calm-cli --test txn_crash_injection -- --ignored |
| 150 | +fi |
| 151 | + |
| 152 | +# ---- summary ------------------------------------------------------------ |
| 153 | +printf '\n\033[1m================ ci-local summary (mode: %s) ================\033[0m\n' "$MODE" |
| 154 | +for p in "${PASS[@]:-}"; do [ -n "$p" ] && printf ' \033[1;32mPASS\033[0m %s\n' "$p"; done |
| 155 | +for f in "${FAIL[@]:-}"; do [ -n "$f" ] && printf ' \033[1;31mFAIL\033[0m %s\n' "$f"; done |
| 156 | +printf '\033[1m%s passed, %s failed\033[0m\n' "${#PASS[@]}" "${#FAIL[@]}" |
| 157 | + |
| 158 | +if [ "${#FAIL[@]}" -ne 0 ]; then |
| 159 | + exit 1 |
| 160 | +fi |
| 161 | +echo "all selected CI checks passed." |
0 commit comments