diff --git a/.automaker/context/agent-push-protocol.md b/.automaker/context/agent-push-protocol.md new file mode 100644 index 0000000..e45fce4 --- /dev/null +++ b/.automaker/context/agent-push-protocol.md @@ -0,0 +1,152 @@ +# Agent Push Protocol — ZERO TOLERANCE + +Every agent push MUST follow this exact sequence. No shortcuts. No exceptions. +Pushing code that fails CI is a wasted cycle and an unacceptable failure. + +**CODE DOES NOT LEAVE THIS MACHINE UNTIL IT PASSES ALL QUALITY GATES.** + +--- + +## The Push Sequence + +### Step 1: Format + +```bash +pnpm run format +git add -u +``` + +### Step 2: Commit + +```bash +git commit -m "type(scope): lowercase message" +``` + +Do NOT use `HUSKY=0`. The pre-commit hook runs gitleaks (secret scanning) +and lint-staged. These are safety checks, not obstacles. + +### Step 3: Push + +```bash +git push origin +``` + +The pre-push hook runs `pnpm run preflight` automatically. This executes: +1. Lint (ESLint) +2. Format check (Prettier) +3. Type check (TypeScript strict) +4. Build (tsc compilation) +5. Test (Vitest) +6. Changeset check (if source changed) +7. Docker CI (act-ci, if Docker is available) +8. Full test suite (all tests, not just changed files) + +If ANY gate fails, the push is blocked. Fix the errors and push again. + +**Do NOT use `--no-verify` to bypass the pre-push hook.** +**Do NOT use `HUSKY=0` to skip hooks.** + +### Step 4: Create PR + Auto-Merge + +```bash +PR_URL=$(gh pr create \ + --repo bookedsolidtech/helixir \ + --base dev \ + --title "type(scope): lowercase description" \ + --body "Description of changes") + +PR_NUMBER=$(echo $PR_URL | grep -oE '[0-9]+$') + +gh pr merge $PR_NUMBER --auto --merge --repo bookedsolidtech/helixir +``` + +--- + +## Enforcement Layers + +| Layer | What | Bypassable? | +|-------|------|-------------| +| pre-commit | gitleaks + lint-staged | Only with `--no-verify` | +| commit-msg | commitlint | Only with `--no-verify` | +| **pre-push** | **`pnpm run preflight` (8 gates including changeset, Docker CI, and full test suite)** | **Only with `--no-verify`** | + +The pre-push hook calls `pnpm run preflight` which runs all 8 gates. +Gate 7 (Docker CI) is the act-ci gate — runs if Docker is available, hard fail if it fails. +Gate 8 (Full test suite) runs all tests when source files changed. +Skip Docker only: `SKIP_ACT=1 git push` +Skip full tests only: `SKIP_FULL_TESTS=1 git push` + +--- + +## What Happens If You Skip Steps + +- **Skip format** → pre-push format check fails → push blocked +- **Skip lint** → pre-push lint fails → push blocked +- **Type errors** → pre-push type-check fails → push blocked +- **Build broken** → pre-push build fails → push blocked +- **Tests fail** → pre-push test fails → push blocked +- **Use --no-verify** → EMERGENCY ONLY — document why in the commit message + +--- + +## Changeset Requirement + +If your changes modify published source code, create a changeset: + +```bash +pnpm exec changeset +``` + +Select the package, bump type, and write a description. +Commit the `.changeset/*.md` file WITH your code changes (same commit). + +--- + +## CI Matrix Parity — Node 20/22/24 + +helixir must pass tests on Node 20, 22, and 24. This mirrors the CI matrix +defined in `.github/workflows/ci-matrix.yml`. + +### When to run `--matrix` + +Run `./scripts/act-ci.sh --matrix` when: +- Modifying `package.json` engines or dependencies +- Adding/changing Node.js-version-specific code paths +- Preparing a release to main +- CI matrix failures are reported on a PR + +### How it works + +The `--matrix` flag sets `ACT_MATRIX_TESTS=true` and `ACT_FULL_TESTS=true`, +which activates the `test-full` job in `act-ci.yml`. That job uses nvm to +install and test against Node 20, 22, and 24 in parallel (fail-fast: false). + +```bash +# Run full matrix locally +./scripts/act-ci.sh --matrix + +# Matrix on ARM64 (no Rosetta emulation, faster on Apple Silicon) +./scripts/act-ci.sh --native --matrix + +# Run full suite on current Node only (no matrix) +./scripts/act-ci.sh --full +``` + +### Enforcement + +Gate 7 of preflight runs `act-ci.sh --native` (standard quality gates). +For matrix parity verification before a release, run manually: + +```bash +./scripts/act-ci.sh --native --matrix +``` + +This is required before merging any PR that touches `src/`, `package.json`, +or Node version configuration. + +--- + +## The One Rule + +If `git push` fails due to the pre-push hook, you do NOT bypass it. Period. +Fix the errors first. Then push. This is non-negotiable. diff --git a/.github/workflows/act-ci.yml b/.github/workflows/act-ci.yml new file mode 100644 index 0000000..a82d612 --- /dev/null +++ b/.github/workflows/act-ci.yml @@ -0,0 +1,132 @@ +# ============================================================================ +# Local CI — Quality gates for nektos/act +# ============================================================================ +# Mirrors the core quality gates from ci.yml but avoids GitHub-specific actions +# (pnpm/action-setup, actions/setup-node) that break in act due to PATH issues +# and missing API context. +# +# Single-job design: helixir is a single-package Node project. Running all +# gates in one job avoids bind-mount contention (parallel jobs sharing +# node_modules via --bind corrupt each other's pnpm install). +# +# Usage: ./scripts/act-ci.sh [--job ] [--list] [--full] [--matrix] +# Jobs: quality-gates (default), test-full (activated by --full or --matrix) +# ============================================================================ + +name: Local CI + +on: + pull_request: + +jobs: + quality-gates: + name: Quality Gates + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Setup pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.9 --activate + pnpm install --frozen-lockfile + - name: Lint + run: pnpm run lint + - name: Format check + run: pnpm run format:check + - name: Type check + run: pnpm run type-check + - name: Build + run: pnpm run build + - name: Test + run: pnpm run test + + # ── Full suite test with Node version matrix (mirrors ci-matrix.yml) ─── + test-full: + name: Test Full (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + if: ${{ env.ACT_MATRIX_TESTS == 'true' || env.ACT_FULL_TESTS == 'true' }} + strategy: + fail-fast: false + matrix: + node-version: [20, 22, 24] + steps: + - uses: actions/checkout@v4 + - name: Setup Node ${{ matrix.node-version }} via nvm + run: | + if [ "${{ matrix.node-version }}" != "" ]; then + export NVM_DIR="$HOME/.nvm" + if [ ! -d "$NVM_DIR" ]; then + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + fi + . "$NVM_DIR/nvm.sh" + nvm install ${{ matrix.node-version }} + nvm use ${{ matrix.node-version }} + echo "Node version: $(node --version)" + fi + - name: Setup pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.9 --activate + pnpm install --frozen-lockfile + - name: Build + run: pnpm run build + - name: Run full test suite with hang watchdog + run: | + # Vitest hang watchdog — kills stale processes after 15s of no output + LOGFILE=$(mktemp /tmp/helixir-test-full.XXXXXX) + STALE_TIMEOUT=15 + POLL_INTERVAL=3 + START_TIME=$(date +%s) + + pnpm exec vitest run --reporter=verbose > "$LOGFILE" 2>&1 & + VITEST_PID=$! + + echo "[test-full] vitest PID=$VITEST_PID, Node ${{ matrix.node-version }}" + + LAST_SIZE=0 + STALE_SECONDS=0 + FORCE_KILLED=false + + while kill -0 "$VITEST_PID" 2>/dev/null; do + sleep "$POLL_INTERVAL" + CURRENT_SIZE=$(stat -c "%s" "$LOGFILE" 2>/dev/null || stat -f "%z" "$LOGFILE" 2>/dev/null || echo 0) + ELAPSED=$(( $(date +%s) - START_TIME )) + + if [ "$CURRENT_SIZE" -eq "$LAST_SIZE" ] && [ "$CURRENT_SIZE" -gt 0 ]; then + STALE_SECONDS=$((STALE_SECONDS + POLL_INTERVAL)) + if [ "$STALE_SECONDS" -ge "$STALE_TIMEOUT" ] && [ "$ELAPSED" -ge 30 ]; then + echo "[test-full] Output stale for ${STALE_SECONDS}s at ${ELAPSED}s — force killing vitest" + kill "$VITEST_PID" 2>/dev/null || true + sleep 1 + kill -9 "$VITEST_PID" 2>/dev/null || true + FORCE_KILLED=true + break + fi + else + STALE_SECONDS=0 + fi + LAST_SIZE=$CURRENT_SIZE + done + + wait "$VITEST_PID" 2>/dev/null || true + + # Determine pass/fail from output + FAILED_TESTS=$(grep -c "^[[:space:]]*[×x]" "$LOGFILE" 2>/dev/null || echo 0) + PASSED_TESTS=$(grep -c "^[[:space:]]*[✓✔]" "$LOGFILE" 2>/dev/null || echo 0) + + echo "" + cat "$LOGFILE" | tail -30 + echo "" + echo "[test-full] Node ${{ matrix.node-version }}: ${PASSED_TESTS} passed, ${FAILED_TESTS} failed" + + if [ "$FORCE_KILLED" = true ]; then + echo "[test-full] vitest was force-killed after teardown hang" + fi + + rm -f "$LOGFILE" + + if [ "$FAILED_TESTS" -gt 0 ]; then + exit 1 + fi diff --git a/.github/workflows/ci-matrix.yml b/.github/workflows/ci-matrix.yml new file mode 100644 index 0000000..2885551 --- /dev/null +++ b/.github/workflows/ci-matrix.yml @@ -0,0 +1,106 @@ +# ============================================================================ +# Matrix CI Pipeline — HELiXiR MCP Server +# ============================================================================ +# Tests across Node.js 20, 22, and 24 to ensure forward-compatibility +# and catch version-specific ESM / native module regressions. +# ============================================================================ + +name: CI Matrix + +on: + push: + branches: [main, dev, staging, 'changeset-release/**'] + paths-ignore: + - '**/*.md' + - '.automaker/**' + - '.claude/**' + - 'LICENSE' + - '.editorconfig' + pull_request: + branches: [main, dev] + paths-ignore: + - '**/*.md' + - '.automaker/**' + - '.claude/**' + - 'LICENSE' + - '.editorconfig' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + matrix-test: + name: Test (Node ${{ matrix.node-version }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + node-version: [20, 22, 24] + os: [ubuntu-latest] + + steps: + # ---- Setup ---- + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # ---- Quality Checks ---- + + - name: Type check + run: pnpm run type-check + + - name: Lint + run: pnpm run lint + + - name: Format check + run: pnpm run format:check + + # ---- Build ---- + + - name: Build + run: pnpm run build + + # ---- Tests ---- + + - name: Test + run: | + # Promotion PRs (staging→main): tests already verified on staging — skip + if [[ "${{ github.head_ref }}" == "staging" && "${{ github.base_ref }}" == "main" ]]; then + echo "Promotion PR (staging → main) — skipping matrix tests" + exit 0 + fi + pnpm run test + + matrix-summary: + name: Matrix Test Summary + runs-on: ubuntu-latest + needs: matrix-test + if: always() + + steps: + - name: Check matrix results + run: | + if [ "${{ needs.matrix-test.result }}" == "failure" ]; then + echo "❌ Matrix tests failed" + exit 1 + else + echo "✅ All matrix tests passed" + fi diff --git a/package.json b/package.json index 54a245e..1100946 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ } }, "engines": { - "node": ">=20.0.0", + "node": "^20.0.0 || ^22.0.0 || ^24.0.0", "pnpm": ">=9" }, "lint-staged": { diff --git a/scripts/act-ci.sh b/scripts/act-ci.sh new file mode 100755 index 0000000..ac8c59f --- /dev/null +++ b/scripts/act-ci.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# scripts/act-ci.sh — Run CI locally via nektos/act +# Usage: ./scripts/act-ci.sh [--job ] [--list] [--native] [--clean] [--full] [--matrix] [--help] +# +# Runs .github/workflows/act-ci.yml — a lightweight mirror of ci.yml that +# avoids GitHub-specific actions (pnpm/action-setup, actions/setup-node) +# which break in act due to PATH issues and missing API context. +# +# Available jobs: quality-gates (default), test-full (activated by --full or --matrix) +# Flags: +# --job Run a specific job only +# --list List available jobs +# --help Show this help message +# --clean Remove all stale act containers before running +# --native Use linux/arm64 native architecture (no Rosetta emulation) +# --full Run full test suite on current Node (triggers test-full job) +# --matrix Run full test suite on Node 20/22/24 matrix (CI Matrix parity) +# +# Performance notes: +# .actrc configures --bind (zero-copy mount), --reuse (keep containers), +# --pull=false (skip image check), --no-cache-server, --action-offline-mode. +# With warm containers: lint ~12s, format ~10s, type-check ~15s, build ~90s. +# +# Docker OOM on Apple Silicon: +# Default mode runs linux/amd64 via Rosetta 2, which uses 2-3x more memory. +# Use --native for linux/arm64 containers (no emulation overhead). +# Use --full to run the complete test suite (default is quality-gates only). +# Use --matrix for full CI Matrix parity (Node 20/22/24). +# Best combo: ./scripts/act-ci.sh --native --matrix +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# ── Prerequisites ──────────────────────────────────────────────────────────── +if ! command -v act &>/dev/null; then + echo "ERROR: 'act' is not installed. Run: brew install act" + exit 1 +fi + +if ! docker info &>/dev/null 2>&1; then + echo "ERROR: Docker is not running. Start Docker Desktop first." + exit 1 +fi + +# ── Container cleanup ──────────────────────────────────────────────────────── +cleanup_stale_containers() { + local stale + stale=$(docker ps -aq --filter "status=exited" --filter "name=act-" 2>/dev/null || true) + if [[ -n "$stale" ]]; then + local count + count=$(echo "$stale" | wc -l | tr -d ' ') + echo "Cleaning up $count stale act container(s)..." + echo "$stale" | xargs docker rm >/dev/null 2>&1 || true + fi +} + +# ── Parse arguments ────────────────────────────────────────────────────────── +WORKFLOW=".github/workflows/act-ci.yml" +JOB_ARGS="" +DO_CLEAN=false +USE_NATIVE=false +USE_FULL=false +USE_MATRIX=false + +show_help() { + sed -n '3,29p' "${BASH_SOURCE[0]}" | sed 's/^# //' | sed 's/^#//' + echo "" + echo "Examples:" + echo " ./scripts/act-ci.sh # Quality gates, current Node" + echo " ./scripts/act-ci.sh --full # Full test suite, current Node" + echo " ./scripts/act-ci.sh --matrix # Full test suite, Node 20/22/24" + echo " ./scripts/act-ci.sh --native --matrix # Matrix tests, ARM64 (no Rosetta)" + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + ;; + --list) + act -W "$WORKFLOW" --list + exit 0 + ;; + --job) + JOB_ARGS="--job $2" + shift 2 + ;; + --clean) + DO_CLEAN=true + shift + ;; + --native) + USE_NATIVE=true + shift + ;; + --full) + USE_FULL=true + shift + ;; + --matrix) + USE_MATRIX=true + USE_FULL=true + shift + ;; + *) + break + ;; + esac +done + +# Always clean stale containers, or do a full clean if requested +if [[ "$DO_CLEAN" == true ]]; then + echo "Cleaning ALL act containers..." + docker ps -aq --filter "name=act-" 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1 || true +else + cleanup_stale_containers +fi + +# ── Build architecture and env flags ────────────────────────────────────────── +ARCH_ARGS="" +ENV_ARGS="--env CI=true" + +if [[ "$USE_NATIVE" == true ]]; then + ARCH_ARGS="--container-architecture linux/arm64" + ARCH_MODE="native ARM64" +else + ARCH_ARGS="--container-architecture linux/amd64" + ARCH_MODE="amd64 (Rosetta)" +fi + +if [[ "$USE_MATRIX" == true ]]; then + ENV_ARGS="$ENV_ARGS --env ACT_MATRIX_TESTS=true --env ACT_FULL_TESTS=true" + TEST_MODE="full suite + Node 20/22/24 matrix (CI Matrix parity)" +elif [[ "$USE_FULL" == true ]]; then + ENV_ARGS="$ENV_ARGS --env ACT_FULL_TESTS=true" + TEST_MODE="full suite (current Node)" +else + TEST_MODE="standard (quality-gates)" +fi + +echo "=== Running CI locally via act ===" +echo "Workflow: $WORKFLOW" +echo "Job: ${JOB_ARGS:-all}" +echo "Mode: $ARCH_MODE" +echo "Tests: $TEST_MODE" +echo "" + +START_TIME=$(date +%s) + +if act pull_request -W "$WORKFLOW" $JOB_ARGS \ + $ENV_ARGS \ + $ARCH_ARGS \ + --eventpath .github/act-event.json \ + "$@"; then + STATUS="passed" + EXIT_CODE=0 +else + STATUS="failed" + EXIT_CODE=1 +fi + +END_TIME=$(date +%s) +DURATION=$((END_TIME - START_TIME)) + +cat > .act-results.json << EOF +{ + "status": "$STATUS", + "workflow": "act-ci.yml", + "job": "${JOB_ARGS:-all}", + "duration_seconds": $DURATION, + "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} +EOF + +echo "" +echo "=== act CI $STATUS in ${DURATION}s ===" +exit $EXIT_CODE diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..a816d6b --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# ============================================================================== +# Preflight — Local CI equivalent. Run before every push. +# ============================================================================== +# Mirrors the CI pipeline exactly so all failures are caught locally. +# Fails fast on first error. +# +# Gates (in order): +# 1. Lint (ESLint) +# 2. Format check (Prettier) +# 3. Type check (TypeScript strict) +# 4. Build (tsc) +# 5. Test (Vitest, Node mode) +# 6. Changeset check (if source changed) +# 7. Docker CI (act — full CI pipeline in Docker containers) +# 8. Full test suite (all tests, not just changed files) +# +# Usage: +# pnpm run preflight +# SKIP_CHANGESET=1 pnpm run preflight # bypass changeset gate (infra-only changes) +# SKIP_ACT=1 pnpm run preflight # bypass Docker CI gate +# SKIP_FULL_TESTS=1 pnpm run preflight # bypass full test suite gate +# ============================================================================== + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +echo "════════════════════════════════════════════════" +echo " HELiXiR Preflight — local CI equivalent" +echo "════════════════════════════════════════════════" +echo "" + +# ── Resolve base branch and common ancestor ────────────────────────────────── + +BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \ + | sed 's|refs/remotes/origin/||' \ + || git rev-parse --abbrev-ref origin/HEAD 2>/dev/null \ + | sed 's|origin/||' \ + || echo "dev") + +COMMON_ANCESTOR=$(git merge-base HEAD "origin/${BASE_BRANCH}" 2>/dev/null || echo "") + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + +# Detect changed source files (src/) +CHANGED_SOURCES="" +if [ -n "$COMMON_ANCESTOR" ]; then + CHANGED_SOURCES=$(git diff --name-only "$COMMON_ANCESTOR"...HEAD \ + | grep -E '^src/' \ + | grep -v '\.test\.ts$' \ + || true) +fi + +# ── Gate 1: Lint ───────────────────────────────────────────────────────────── + +echo "▶ [1/8] Lint" +pnpm run lint +echo " ✓ Lint passed" +echo "" + +# ── Gate 2: Format check ───────────────────────────────────────────────────── + +echo "▶ [2/8] Format check" +pnpm run format:check +echo " ✓ Format passed" +echo "" + +# ── Gate 3: Type check ─────────────────────────────────────────────────────── + +echo "▶ [3/8] Type check" +pnpm run type-check +echo " ✓ Type check passed" +echo "" + +# ── Gate 4: Build ──────────────────────────────────────────────────────────── + +echo "▶ [4/8] Build" +pnpm run build +echo " ✓ Build passed" +echo "" + +# ── Gate 5: Test ───────────────────────────────────────────────────────────── + +echo "▶ [5/8] Test" +pnpm run test +echo " ✓ Tests passed" +echo "" + +# ── Gate 6: Changeset ──────────────────────────────────────────────────────── + +echo "▶ [6/8] Changeset" + +if [ "${SKIP_CHANGESET:-0}" = "1" ]; then + echo " ✓ SKIP_CHANGESET=1 — bypassed" +elif [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "staging" || "$CURRENT_BRANCH" == "dev" ]] \ + || [[ "$CURRENT_BRANCH" == *"audit/"* ]]; then + echo " ✓ Changeset check skipped (branch: $CURRENT_BRANCH)" +elif [ -n "$COMMON_ANCESTOR" ] && [ -n "$CHANGED_SOURCES" ]; then + CHANGESET_ADDED=$(git diff --name-only "$COMMON_ANCESTOR"...HEAD \ + | grep '^\.changeset/.*\.md$' | grep -v 'README\.md' || true) + + if [ -z "$CHANGESET_ADDED" ]; then + echo "" + echo " ✗ CHANGESET REQUIRED — source was modified but no changeset found." + echo "" + echo " Run: pnpm exec changeset" + echo " Select the package, bump type, and write a description." + echo " Commit the .changeset/*.md file with your changes." + echo "" + echo " To bypass for infra-only work: SKIP_CHANGESET=1 pnpm run preflight" + echo "" + exit 1 + fi + echo " ✓ Changeset found: $CHANGESET_ADDED" +else + echo " ✓ No source changes — changeset not required" +fi +echo "" + +# ── Gate 7: Docker CI (act) ────────────────────────────────────────────────── + +echo "▶ [7/8] Docker CI (act)" + +if [ "${SKIP_ACT:-0}" = "1" ]; then + echo " ⚠ SKIP_ACT=1 — Docker CI gate bypassed" +elif ! command -v act &>/dev/null || ! docker info &>/dev/null 2>&1; then + echo " ⚠ WARNING: Docker CI gate skipped — Docker not running or act not installed" + echo " CI may fail on push. Install: brew install act && open -a Docker" +else + echo " Running full CI in Docker..." + if ./scripts/act-ci.sh --native; then + echo " ✓ Docker CI passed" + else + echo "" + echo " ✗ DOCKER CI FAILED — do NOT push." + echo " Fix the errors above and re-run: pnpm run preflight" + exit 1 + fi +fi +echo "" + +# ── Gate 8: Full test suite ─────────────────────────────────────────────────── + +echo "▶ [8/8] Full test suite" + +if [ "${SKIP_FULL_TESTS:-0}" = "1" ]; then + echo " ✓ SKIP_FULL_TESTS=1 — full test suite bypassed" +elif [ -z "$CHANGED_SOURCES" ]; then + echo " ✓ No source changes — full test suite not required" +else + echo " Running full test suite with hang watchdog..." + + # Vitest hang watchdog — kills stale processes after 15s of no output + LOGFILE=$(mktemp /tmp/helixir-preflight-full.XXXXXX) + STALE_TIMEOUT=15 + POLL_INTERVAL=3 + START_WT=$(date +%s) + + pnpm exec vitest run --reporter=verbose > "$LOGFILE" 2>&1 & + VITEST_PID=$! + + LAST_SIZE=0 + STALE_SECONDS=0 + FORCE_KILLED=false + + while kill -0 "$VITEST_PID" 2>/dev/null; do + sleep "$POLL_INTERVAL" + CURRENT_SIZE=$(stat -c "%s" "$LOGFILE" 2>/dev/null || stat -f "%z" "$LOGFILE" 2>/dev/null || echo 0) + ELAPSED=$(( $(date +%s) - START_WT )) + + if [ "$CURRENT_SIZE" -eq "$LAST_SIZE" ] && [ "$CURRENT_SIZE" -gt 0 ]; then + STALE_SECONDS=$((STALE_SECONDS + POLL_INTERVAL)) + if [ "$STALE_SECONDS" -ge "$STALE_TIMEOUT" ] && [ "$ELAPSED" -ge 30 ]; then + echo " [watchdog] Output stale for ${STALE_SECONDS}s at ${ELAPSED}s — force killing vitest" + kill "$VITEST_PID" 2>/dev/null || true + sleep 1 + kill -9 "$VITEST_PID" 2>/dev/null || true + FORCE_KILLED=true + break + fi + else + STALE_SECONDS=0 + fi + LAST_SIZE=$CURRENT_SIZE + done + + wait "$VITEST_PID" 2>/dev/null || true + + FAILED_TESTS=$(grep -c "^[[:space:]]*[×x]" "$LOGFILE" 2>/dev/null || echo 0) + PASSED_TESTS=$(grep -c "^[[:space:]]*[✓✔]" "$LOGFILE" 2>/dev/null || echo 0) + + echo "" + tail -20 "$LOGFILE" + echo "" + echo " [full suite] ${PASSED_TESTS} passed, ${FAILED_TESTS} failed" + + if [ "$FORCE_KILLED" = true ]; then + echo " [watchdog] vitest was force-killed after teardown hang" + fi + + rm -f "$LOGFILE" + + if [ "$FAILED_TESTS" -gt 0 ]; then + echo "" + echo " ✗ FULL TEST SUITE FAILED — do NOT push." + echo " Fix the errors above and re-run: pnpm run preflight" + echo " To bypass: SKIP_FULL_TESTS=1 pnpm run preflight" + exit 1 + fi + + echo " ✓ Full test suite passed" +fi +echo "" + +# ── All gates passed ────────────────────────────────────────────────────────── + +echo "════════════════════════════════════════════════" +echo " ✓ All preflight gates passed — safe to push!" +echo "════════════════════════════════════════════════"