#!/usr/bin/env bash # The self-enforcing gate. Classifies the task delta and runs a proportional # gate: docs-only, lib-only, frontend-only, or full Rust/Bevy. # # Usage: # ./tools/check.sh # auto-classify from changed files # ./tools/check.sh --docs # corpus + wiki + env only # ./tools/check.sh --lib # fmt, lib tests, terminal clippy, cheap bevy check # ./tools/check.sh --frontend # fmt, bevy bin tests, bevy clippy # ./tools/check.sh --full # everything (also MISALIGNED_FORCE_RUST_GATE=1) # ./tools/check.sh --land # land policy: auto-classify (full when unclear) # # Concurrent agents: at most one Rust gate runs at a time on this machine # (mkdir lock). Docs gates never take the lock. `tools/claim.sh` records # advisory task surfaces; it is not a semantic mutex. # # An unclassifiable clean primary checkout stays conservative (full). Tangled # runs the same docs gates separately and uses the knot's complete ref-update # file list through the workflow's native push path filter. # Exit non-zero on any failure. set -euo pipefail cd "$(dirname "$0")/.." fail=0 step() { printf '\n=== %s ===\n' "$1"; } # ── Mode selection ─────────────────────────────────────────────────────── mode=auto for arg in "$@"; do case "$arg" in --docs|--lib|--frontend|--full|--land) mode=${arg#--} ;; -h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) echo "unknown argument: $arg (try --docs|--lib|--frontend|--full|--land)" exit 2 ;; esac done if [ "${MISALIGNED_FORCE_RUST_GATE:-0}" = "1" ]; then mode=full fi # --land is the named land-phase gate: same as auto (full when unclassifiable). if [ "$mode" = "land" ]; then mode=auto printf 'check phase: land (auto-classify)\n' fi changed_files() { # Classify both uncommitted work and commits on a linked task worktree. # CI and the primary checkout normally have a .git directory (not the # .git file used by linked worktrees), so they retain the full gate. { git diff --name-only HEAD -- git ls-files --others --exclude-standard if [ -f .git ] && git rev-parse --verify origin/main >/dev/null 2>&1; then local base base=$(git merge-base HEAD origin/main) git diff --name-only "$base"...HEAD -- fi } | sort -u } # Returns: docs | lib | frontend | full classify_auto() { local changed path changed=$(changed_files) if [ -z "$changed" ]; then # CI, primary checkout, or a task branch with no delta: run everything. printf 'full\n' return fi local has_rust=0 has_lib=0 has_frontend=0 has_cargo_meta=0 while IFS= read -r path; do [ -z "$path" ] && continue case "$path" in Cargo.toml|Cargo.lock|build.rs|rust-toolchain*|.cargo/*|tests/*|benches/*|examples/*|crates/*/Cargo.toml) has_rust=1 has_cargo_meta=1 ;; crates/misaligned-bevy/*|crates/misaligned-assets/*) has_rust=1 has_frontend=1 ;; crates/misaligned-core/*|crates/misaligned-terminal/*) has_rust=1 has_lib=1 ;; assets/plots/*) # Plot TOML is compiled executable content. The generated catalog and # semantic validator only run when the core crate is built. has_rust=1 has_lib=1 ;; # Legacy monorepo paths (should not appear after workspace land) src/bin/bevy.rs|src/bin/assets|src/bin/assets/*) has_rust=1 has_frontend=1 ;; src/*) has_rust=1 has_lib=1 ;; esac done <<< "$changed" if [ "$has_rust" -eq 0 ]; then printf 'docs\n' elif [ "$has_cargo_meta" -eq 1 ]; then printf 'full\n' elif [ "$has_frontend" -eq 1 ] && [ "$has_lib" -eq 0 ]; then printf 'frontend\n' elif [ "$has_lib" -eq 1 ] && [ "$has_frontend" -eq 0 ]; then printf 'lib\n' else printf 'full\n' fi } if [ "$mode" = "auto" ]; then mode=$(classify_auto) fi rust_gate=0 case "$mode" in docs) rust_gate=0 ;; lib|frontend|full) rust_gate=1 ;; *) echo "internal error: unknown mode $mode"; exit 2 ;; esac printf 'check mode: %s\n' "$mode" guard_cargo_target_dir() { # Sharing one CARGO_TARGET_DIR across git worktrees can produce false-green # checks: Cargo may reuse the local package artifact from another checkout. # Seed private targets with tools/seed-cargo-target.sh instead. [ -n "${CARGO_TARGET_DIR:-}" ] || return 0 [ "${MISALIGNED_ALLOW_EXTERNAL_TARGET:-0}" = "1" ] && return 0 local root target default_target root=$(pwd -P) mkdir -p "$CARGO_TARGET_DIR" "$root/target" target=$(cd "$CARGO_TARGET_DIR" && pwd -P) default_target=$(cd "$root/target" && pwd -P) if [ "$target" != "$default_target" ]; then echo "FAIL: CARGO_TARGET_DIR points outside this worktree: $target" echo " Cargo target dirs are not safe to share across Misaligned worktrees." echo " Run tools/seed-cargo-target.sh once, then unset CARGO_TARGET_DIR." echo " Override only for a deliberate isolated target with MISALIGNED_ALLOW_EXTERNAL_TARGET=1." exit 1 fi } # ── Always-on cheap steps ──────────────────────────────────────────────── step "script syntax" for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/design_amendment_gate.sh tools/seed-cargo-target.sh \ tools/ci-pkg-config.sh \ tools/claim.sh tools/worktree-new.sh tools/worktree-done.sh tools/heartbeat.sh \ tools/ledger_index.sh tools/test_corpus_engine.sh tools/test_design_amendment_gate.sh \ tools/test_bevy_headless.sh \ tools/test_site_deploy.sh tools/test_site_smoke.sh tools/site-smoke.sh \ tools/observed-run.sh tools/test_observed_run.sh \ tools/task.sh tools/doctor.sh tools/bevy-headless.sh; do [ -f "$script" ] || continue bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; } done for program in tools/corpus_engine.py tools/work_orders.py tools/project-status.py \ tools/scenario.py tools/test_project_ops.py tools/test_ci_workflows.py; do [ -f "$program" ] || continue python3 -m py_compile "$program" 2>/dev/null || { echo "FAIL: Python syntax: $program" fail=1 } done step "gate dependency preflight" # Gates require python3 (corpus engine). Prove they fail clearly when it is # absent from PATH, without stripping bash itself. bash_bin=$(command -v bash) partial_path=$(mktemp -d) # Provide only bash — no python3. ln -s "$bash_bin" "$partial_path/bash" for gate in tools/corpus_gate.sh tools/wiki_gate.sh; do if gate_output=$(PATH="$partial_path" "$bash_bin" "$gate" 2>&1); then echo "FAIL: $gate returned success without its required commands" fail=1 elif ! grep -q '^FAIL: required command unavailable: python3' <<< "$gate_output"; then echo "FAIL: $gate did not fail through its command preflight" echo "$gate_output" | sed 's/^/ /' fail=1 else echo "$gate: rejects missing python3" fi done rm -rf "$partial_path" # Docs gates run in parallel with each other (and with the Rust gate when # both are needed). They never take the rust flock. docs_pids=() docs_logs=() start_docs_gate() { local name="$1" cmd="$2" local log log=$(mktemp) docs_logs+=("$log") ( # shellcheck disable=SC2086 eval "$cmd" >"$log" 2>&1 ) & docs_pids+=("$!") printf ' started %s (pid %s)\n' "$name" "$!" } step "docs gates (parallel)" start_docs_gate "corpus" "bash tools/corpus_gate.sh" start_docs_gate "wiki" "bash tools/wiki_gate.sh" start_docs_gate "corpus-engine-fixtures" "bash tools/test_corpus_engine.sh" start_docs_gate "design-amendment-fixtures" "bash tools/test_design_amendment_gate.sh" start_docs_gate "bevy-headless-fixtures" "bash tools/test_bevy_headless.sh" start_docs_gate "ci-workflow-fixtures" "python3 tools/test_ci_workflows.py" start_docs_gate "site-deploy-fixtures" "bash tools/test_site_deploy.sh" start_docs_gate "site-smoke-fixtures" "bash tools/test_site_smoke.sh" start_docs_gate "observed-run-fixtures" "bash tools/test_observed_run.sh" start_docs_gate "ledger-index" "bash tools/ledger_index.sh --check" start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions && python3 tools/project-status.py --check --offline" start_docs_gate "project-operations-fixtures" "python3 tools/test_project_ops.py" # The single-quoted program is evaluated inside start_docs_gate, not here. # shellcheck disable=SC2016 start_docs_gate "env-registry" ' env_reg=wiki/engineering/env.md env_missing=0 for v in $(grep -rhoIE --exclude='*.pyc' --exclude-dir='__pycache__' \ "MISALIGNED_[A-Z_]+" crates tools .githooks 2>/dev/null | sort -u); do grep -q "$v" "$env_reg" || { echo "FAIL: $v read in the tree but missing from $env_reg"; env_missing=1; } done if grep -rn "env::var" crates/misaligned-core --include="*.rs" | grep -q .; then echo "FAIL: the sim library must not read the environment (env registry rule)" env_missing=1 fi [ "$env_missing" -eq 0 ] echo "env registry: OK" ' setup_local_pkg_config() { # Some local/dev images have the runtime libraries Bevy needs but not the # distro dev .pc files. Linux-only shim (macOS has no ldconfig). command -v ldconfig >/dev/null 2>&1 || return 0 pcdir=$(mktemp -d) local pclibdir="$pcdir/lib" mkdir -p "$pclibdir" gen_pc_if_missing() { local pc_name="$1" lib_name="${2:-$1}" pkg-config --exists "$pc_name" 2>/dev/null && return 0 command -v ldconfig >/dev/null 2>&1 || return 0 local lib_path lib_path=$(ldconfig -p 2>/dev/null | awk -v lib="lib${lib_name}[.]so" '$1 ~ ("^" lib "([.]|$)") && found == "" { found=$NF } END { if (found != "") print found }') if [ -z "$lib_path" ]; then echo "WARNING: lib${lib_name}.so not found by ldconfig; ${pc_name}.pc not generated" return 0 fi ln -sf "$lib_path" "$pclibdir/lib${lib_name}.so" cat > "$pcdir/${pc_name}.pc" < ${lib_path}" } gen_pc_if_missing alsa asound gen_pc_if_missing libudev udev if find "$pcdir" -name '*.pc' -print -quit | grep -q .; then export PKG_CONFIG_PATH="$pcdir${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" fi } run_agent_smoke() { # Build once, run the binary three times. Avoids three cargo-run driver # startups that used to dominate mid-gate wall time on a warm cache. step "agent mode smoke" local tmp_a tmp_b tmp_c agent_script bin tmp_a=$(mktemp) tmp_b=$(mktemp) tmp_c=$(mktemp) # Cross the real silent boundary before probing the ordinary parser. At the # fresh rate Ears lands on tick 15; 25 ticks leaves deterministic slack # without approaching the first pilot deadline. agent_script=$'think\nwait 25\nsalvage\nwait 1\npeople\nreview recordings\nresearch\nmask off\nhelp\nquit\n' cargo build --quiet -p misaligned-terminal --bin misaligned \ || { echo "FAIL: agent smoke build"; rm -f "$tmp_a" "$tmp_b" "$tmp_c"; return 1; } bin=./target/debug/misaligned [ -x "$bin" ] || bin=target/debug/misaligned printf '%s' "$agent_script" | "$bin" --agent --seed 1 > "$tmp_a" \ || { echo "FAIL: agent mode smoke run"; rm -f "$tmp_a" "$tmp_b" "$tmp_c"; return 1; } printf '%s' "$agent_script" | "$bin" --agent --seed 1 > "$tmp_b" \ || { echo "FAIL: agent mode determinism rerun"; rm -f "$tmp_a" "$tmp_b" "$tmp_c"; return 1; } printf '%s' "$agent_script" | "$bin" --agent --seed 2 > "$tmp_c" \ || { echo "FAIL: agent mode alternate-seed run"; rm -f "$tmp_a" "$tmp_b" "$tmp_c"; return 1; } local smoke_fail=0 cmp -s "$tmp_a" "$tmp_b" || { echo "FAIL: same --seed agent runs differ"; smoke_fail=1; } cmp -s "$tmp_a" "$tmp_c" && { echo "FAIL: different --seed agent runs matched"; smoke_fail=1; } LC_ALL=C grep -q $'\033' "$tmp_a" && { echo "FAIL: agent mode emitted ANSI escapes"; smoke_fail=1; } for pat in "MISALIGNED" "PEOPLE" "RESEARCH" "intensity " "help: wait N" "Opened processing sink" "-- ok tick:"; do grep -q -- "$pat" "$tmp_a" || { echo "FAIL: agent mode output missing '$pat'"; smoke_fail=1; } done rm -f "$tmp_a" "$tmp_b" "$tmp_c" return "$smoke_fail" } # Portable exclusive lock (macOS has no flock(1); mkdir is atomic on POSIX). RUST_LOCK_DIR=${MISALIGNED_RUST_GATE_LOCK:-/tmp/misaligned-rust-gate.lock} acquire_rust_lock() { local waited=0 holder while ! mkdir "$RUST_LOCK_DIR" 2>/dev/null; do holder="?" [ -f "$RUST_LOCK_DIR/pid" ] && holder=$(cat "$RUST_LOCK_DIR/pid" 2>/dev/null || echo "?") # Steal a stale lock if the holder is gone. if [ "$holder" != "?" ] && ! kill -0 "$holder" 2>/dev/null; then echo "rust gate: stealing stale lock from dead pid $holder" rm -rf "$RUST_LOCK_DIR" continue fi if [ "${MISALIGNED_RUST_GATE_WAIT:-1}" = "0" ]; then echo "FAIL: another misaligned Rust gate holds $RUST_LOCK_DIR (pid $holder)" echo " Wait for it, or set MISALIGNED_RUST_GATE_WAIT=1 to queue." return 1 fi if [ "$waited" -eq 0 ]; then printf 'rust gate: waiting for lock %s (held by pid %s)\n' "$RUST_LOCK_DIR" "$holder" fi slept=$((waited % 15)) [ "$slept" -eq 0 ] && [ "$waited" -gt 0 ] && \ printf 'rust gate: still waiting (%ss, holder pid %s)\n' "$waited" "$holder" sleep 1 waited=$((waited + 1)) done printf '%s\n' "$$" > "$RUST_LOCK_DIR/pid" printf 'rust gate: acquired lock (mode=%s, pid=%s)\n' "$mode" "$$" return 0 } release_rust_lock() { rm -rf "$RUST_LOCK_DIR" } run_rust_gate() { guard_cargo_target_dir setup_local_pkg_config # Serialize concurrent agents. Docs-only work never reaches here. acquire_rust_lock || return 1 local rc=0 # Explicit release (portable; avoid RETURN-trap leak into the caller). case "$mode" in lib) step "format check" cargo fmt --check || { echo "FAIL: run 'cargo fmt'"; rc=1; } if [ "$rc" -eq 0 ]; then step "tests (misaligned-core)" cargo test --quiet -p misaligned-core || { echo "FAIL: core tests"; rc=1; } fi if [ "$rc" -eq 0 ]; then step "clippy (core + terminal)" cargo clippy --quiet -p misaligned-core -p misaligned-terminal --all-targets -- -D warnings \ || { echo "FAIL: clippy"; rc=1; } fi # Core API changes can break the Bevy binary without touching it. if [ "$rc" -eq 0 ]; then step "bevy check (core API surface)" cargo check --quiet -p misaligned-bevy \ || { echo "FAIL: bevy check after core change"; rc=1; } fi if [ "$rc" -eq 0 ]; then run_agent_smoke || rc=1 fi ;; frontend) step "format check" cargo fmt --check || { echo "FAIL: run 'cargo fmt'"; rc=1; } if [ "$rc" -eq 0 ]; then step "tests (bevy + assets)" cargo test --quiet -p misaligned-bevy -p misaligned-assets \ || { echo "FAIL: bevy/assets tests"; rc=1; } fi if [ "$rc" -eq 0 ]; then step "clippy (bevy + assets)" cargo clippy --quiet -p misaligned-bevy -p misaligned-assets --all-targets -- -D warnings \ || { echo "FAIL: clippy bevy/assets"; rc=1; } fi if [ "$rc" -eq 0 ] && [ "${MISALIGNED_BEVY_SMOKE:-0}" = "1" ]; then step "headless Bevy evidence" # cargo test and clippy do not produce target/debug/misaligned-bevy. # Let the helper build that real binary so smoke works in a freshly # seeded worktree as well as one with prior local runs. bash tools/bevy-headless.sh dark || rc=1 elif [ "$rc" -eq 0 ]; then echo "headless Bevy evidence: SKIP (set MISALIGNED_BEVY_SMOKE=1)" fi ;; full) step "format check" cargo fmt --check || { echo "FAIL: run 'cargo fmt'"; rc=1; } if [ "$rc" -eq 0 ]; then step "tests (workspace)" cargo test --quiet --workspace || { echo "FAIL: workspace tests"; rc=1; } fi if [ "$rc" -eq 0 ]; then step "clippy (workspace)" cargo clippy --quiet --workspace --all-targets -- -D warnings \ || { echo "FAIL: clippy workspace"; rc=1; } fi if [ "$rc" -eq 0 ]; then run_agent_smoke || rc=1 fi if [ "$rc" -eq 0 ] && [ "${MISALIGNED_BEVY_SMOKE:-0}" = "1" ]; then step "headless Bevy evidence" bash tools/bevy-headless.sh dark || rc=1 fi ;; esac release_rust_lock return "$rc" } if [ "$rust_gate" -eq 1 ]; then if ! run_rust_gate; then fail=1 fi else step "rust gate" echo "SKIP: mode=$mode (no Rust-impacting paths, or --docs)." fi # ── Join docs gates ────────────────────────────────────────────────────── step "docs gates (join)" i=0 for pid in "${docs_pids[@]}"; do log=${docs_logs[$i]} if wait "$pid"; then # Show the gate's one-line OK (or last line). tail -n 3 "$log" | sed 's/^/ /' else echo "FAIL: docs gate exited non-zero:" sed 's/^/ /' "$log" fail=1 fi rm -f "$log" i=$((i + 1)) done # Design-corpus enforcement for src/ + binding page co-touch is enforced by # .githooks/pre-commit and .tangled/workflows/spec-check.yml, not here. if [ "$fail" -ne 0 ]; then printf '\nCHECK FAILED (mode=%s)\n' "$mode" exit 1 fi printf '\nALL CHECKS PASSED (mode=%s)\n' "$mode"