diff --git a/.githooks/pre-push b/.githooks/pre-push index cd6b5cd4..e9c7d8da 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,23 +1,18 @@ #!/usr/bin/env bash set -euo pipefail +# Pre-push hook: runs quality gate before pushing +# Skip with: git push --no-verify -# Pre-push hook: run clippy and tests before pushing. -# Install: git config core.hooksPath .githooks +REPO_ROOT="$(git rev-parse --show-toplevel)" +SCRIPT_DIR="$REPO_ROOT/scripts/ci" -echo "pre-push: running clippy..." -if ! cargo clippy --all --benches --tests --examples --all-features -- -D warnings; then - echo "" - echo "Push blocked: clippy warnings found." - echo "To bypass: git push --no-verify" - exit 1 +# Default: baseline quality gate +"$SCRIPT_DIR/quality_gate.sh" + +# Optional strict delta lint (env-gated) +if [ "${IRONCLAW_STRICT_DELTA_LINT:-0}" = "1" ]; then + "$SCRIPT_DIR/delta_lint.sh" "$1" +elif [ "${IRONCLAW_STRICT_LINT:-0}" = "1" ]; then + echo "==> clippy (strict: all warnings)" + cargo clippy --locked --all-targets -- -D warnings fi - -echo "pre-push: running tests..." -if ! cargo test; then - echo "" - echo "Push blocked: tests failed." - echo "To bypass: git push --no-verify" - exit 1 -fi - -echo "pre-push: all checks passed." diff --git a/scripts/ci/delta_lint.sh b/scripts/ci/delta_lint.sh new file mode 100755 index 00000000..c64b91a7 --- /dev/null +++ b/scripts/ci/delta_lint.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +set -euo pipefail +# Delta lint: only fail on clippy warnings/errors that touch changed lines. +# Compares the current branch against the merge base with the upstream default branch. + +CLIPPY_OUT="" +DIFF_OUT="" +CLIPPY_STDERR="" + +cleanup() { + [ -n "$CLIPPY_OUT" ] && rm -f "$CLIPPY_OUT" + [ -n "$DIFF_OUT" ] && rm -f "$DIFF_OUT" + [ -n "$CLIPPY_STDERR" ] && rm -f "$CLIPPY_STDERR" +} +trap cleanup EXIT + +# Verify python3 is available (needed for diagnostic filtering) +if ! command -v python3 &>/dev/null; then + echo "ERROR: python3 is required for delta lint but not found" + exit 1 +fi + +# Accept optional remote name argument; default to dynamic detection +REMOTE="${1:-}" + +# Determine the upstream base ref dynamically +BASE_REF="" +if [ -n "$REMOTE" ]; then + # Use the provided remote name + if [ -z "$BASE_REF" ]; then + BASE_REF=$(git symbolic-ref "refs/remotes/$REMOTE/HEAD" 2>/dev/null | sed 's|refs/remotes/||' || true) + fi + if [ -z "$BASE_REF" ] && git rev-parse --verify "$REMOTE/main" &>/dev/null; then + BASE_REF="$REMOTE/main" + fi + if [ -z "$BASE_REF" ] && git rev-parse --verify "$REMOTE/master" &>/dev/null; then + BASE_REF="$REMOTE/master" + fi +else + # Try the remote HEAD symbolic ref (works for any default branch name) + if [ -z "$BASE_REF" ]; then + BASE_REF=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||' || true) + fi + # Fall back to common default branch names + if [ -z "$BASE_REF" ] && git rev-parse --verify origin/main &>/dev/null; then + BASE_REF="origin/main" + fi + if [ -z "$BASE_REF" ] && git rev-parse --verify origin/master &>/dev/null; then + BASE_REF="origin/master" + fi +fi +if [ -z "$BASE_REF" ]; then + echo "WARNING: could not determine upstream base branch, skipping delta lint" + exit 0 +fi + +# Compute merge base +BASE=$(git merge-base "$BASE_REF" HEAD 2>/dev/null) || { + echo "WARNING: git merge-base failed for $BASE_REF, skipping delta lint" + exit 0 +} + +# Find changed .rs files +CHANGED_RS=$(git diff --name-only "$BASE" -- '*.rs' || true) +if [ -z "$CHANGED_RS" ]; then + echo "==> delta lint: no .rs files changed, skipping" + exit 0 +fi + +echo "==> delta lint: checking changed lines since $(echo "$BASE" | head -c 10)..." + +# Extract unified-0 diff for changed line ranges +DIFF_OUT=$(mktemp "${TMPDIR:-/tmp}/ironclaw-diff.XXXXXX") +git diff --unified=0 "$BASE" -- '*.rs' > "$DIFF_OUT" + +# Run clippy with JSON output (stderr shows compilation progress/errors) +CLIPPY_OUT=$(mktemp "${TMPDIR:-/tmp}/ironclaw-clippy.XXXXXX") +CLIPPY_STDERR=$(mktemp "${TMPDIR:-/tmp}/ironclaw-clippy-err.XXXXXX") +cargo clippy --locked --all-targets --message-format=json > "$CLIPPY_OUT" 2>"$CLIPPY_STDERR" || true + +# Show compilation errors if clippy produced no JSON output +if [ ! -s "$CLIPPY_OUT" ] && [ -s "$CLIPPY_STDERR" ]; then + echo "ERROR: clippy failed to produce output. Compilation errors:" + cat "$CLIPPY_STDERR" + exit 1 +fi + +# Get repo root for path normalization in Python +REPO_ROOT="$(git rev-parse --show-toplevel)" + +# Filter clippy diagnostics against changed line ranges +python3 - "$DIFF_OUT" "$CLIPPY_OUT" "$REPO_ROOT" <<'PYEOF' +import json +import re +import sys +import os + +def parse_diff(diff_path): + """Parse unified-0 diff to extract {file: [[start, end], ...]} changed ranges.""" + changed = {} + current_file = None + with open(diff_path) as f: + for line in f: + # Match +++ b/path/to/file.rs or +++ /dev/null (deletion) + if line.startswith('+++ /dev/null'): + current_file = None + continue + m = re.match(r'^\+\+\+ b/(.+)$', line) + if m: + current_file = m.group(1) + if current_file not in changed: + changed[current_file] = [] + continue + # Match @@ hunk headers: @@ -old,count +new,count @@ + m = re.match(r'^@@ .+ \+(\d+)(?:,(\d+))? @@', line) + if m and current_file: + start = int(m.group(1)) + count = int(m.group(2)) if m.group(2) is not None else 1 + if count == 0: + continue + end = start + count - 1 + changed[current_file].append([start, end]) + return changed + +def normalize_path(path, repo_root): + """Normalize absolute path to relative (from repo root).""" + if os.path.isabs(path): + if path.startswith(repo_root): + return os.path.relpath(path, repo_root) + return path + +def in_changed_range(file_path, line_start, line_end, changed_ranges, repo_root): + """Check if file:[line_start, line_end] overlaps any changed range.""" + rel = normalize_path(file_path, repo_root) + ranges = changed_ranges.get(rel) + if not ranges: + return False + return any(start <= line_end and line_start <= end for start, end in ranges) + +def main(): + diff_path = sys.argv[1] + clippy_path = sys.argv[2] + repo_root = sys.argv[3] + + changed_ranges = parse_diff(diff_path) + + blocking = [] + baseline = [] + + with open(clippy_path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + + if msg.get("reason") != "compiler-message": + continue + + cm = msg.get("message", {}) + level = cm.get("level", "") + if level not in ("warning", "error"): + continue + + rendered = cm.get("rendered", "").strip() + + # Errors are always blocking regardless of location + if level == "error": + blocking.append(rendered) + continue + + # For warnings, only block if they overlap changed lines + spans = cm.get("spans", []) + primary = None + for s in spans: + if s.get("is_primary"): + primary = s + break + if not primary: + if spans: + primary = spans[0] + else: + baseline.append(rendered) + continue + + file_name = primary.get("file_name", "") + line_start = primary.get("line_start", 0) + line_end = primary.get("line_end", line_start) + + if in_changed_range(file_name, line_start, line_end, changed_ranges, repo_root): + blocking.append(rendered) + else: + baseline.append(rendered) + + if baseline: + print(f"\n--- Baseline warnings (not in changed lines, informational) [{len(baseline)}] ---") + for w in baseline[:10]: + print(w) + if len(baseline) > 10: + print(f" ... and {len(baseline) - 10} more") + + if blocking: + print(f"\n*** BLOCKING: {len(blocking)} issue(s) in changed lines ***") + for w in blocking: + print(w) + sys.exit(1) + else: + print("\n==> delta lint: passed (no issues in changed lines)") + sys.exit(0) + +if __name__ == "__main__": + main() +PYEOF diff --git a/scripts/ci/quality_gate.sh b/scripts/ci/quality_gate.sh new file mode 100755 index 00000000..83a62e02 --- /dev/null +++ b/scripts/ci/quality_gate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> fmt check" +cargo fmt --all -- --check + +echo "==> clippy (correctness)" +cargo clippy --locked --all-targets -- -D clippy::correctness + +if [ "${IRONCLAW_PREPUSH_TEST:-1}" = "1" ]; then + echo "==> tests (skip with IRONCLAW_PREPUSH_TEST=0)" + cargo test --locked --lib +fi diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index faa5aa2c..4d272f49 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -56,6 +56,9 @@ if [ -n "$HOOKS_DIR" ]; then echo " commit-msg hook installed (regression test enforcement)" ln -sf "$SCRIPTS_ABS/pre-commit-safety.sh" "$HOOKS_DIR/pre-commit" echo " pre-commit hook installed (UTF-8, case-sensitivity, /tmp, redaction checks)" + REPO_ROOT="$(git rev-parse --show-toplevel)" + ln -sf "$REPO_ROOT/.githooks/pre-push" "$HOOKS_DIR/pre-push" + echo " pre-push hook installed (quality gate + optional delta lint)" else echo " Skipped: not a git repository" fi