Skip to content
18 changes: 18 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
# Pre-push hook: runs quality gate before pushing
# Skip with: git push --no-verify

REPO_ROOT="$(git rev-parse --show-toplevel)"
SCRIPT_DIR="$REPO_ROOT/scripts/ci"

# Default: baseline quality gate
Comment on lines +1 to +9
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description focus on adding a pre-push hook + delta linting, but this PR also includes substantial runtime and architecture changes (e.g., orchestrator setup refactor, tunnel startup refactor, lightweight routine tool execution, token budgeting, secrets/db handle factories, etc.). Please either update the PR description/title to reflect the full scope or split the non-hook changes into separate PRs to keep review/rollback risk manageable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR only adds/modifies .githooks/pre-push, scripts/ci/delta_lint.sh, scripts/ci/quality_gate.sh, scripts/dev-setup.sh. Other files in the diff are from the staging base.

"$SCRIPT_DIR/quality_gate.sh"

# Optional strict delta lint (env-gated)
if [ "${IRONCLAW_STRICT_DELTA_LINT:-0}" = "1" ]; then
"$SCRIPT_DIR/delta_lint.sh"
elif [ "${IRONCLAW_STRICT_LINT:-0}" = "1" ]; then
Comment on lines +12 to +15
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pre-push hook ignores the standard hook arguments (remote name/URL) and always runs delta_lint with its internal default-remote assumptions. That can make delta lint compare against origin even when pushing to a different remote. Pass "$1" (remote name) through to delta_lint.sh and have delta_lint use that remote when resolving the default branch/merge-base.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865ce — pre-push hook now passes $1 (remote name) to delta_lint.sh.

echo "==> clippy (strict: all warnings)"
cargo clippy --locked --all-targets -- -D warnings
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — the pre-push hook is a fast local sanity check, not a CI replacement. Running full feature matrices and --all --benches --tests --examples locally would take too long and cause developers to skip with --no-verify. CI handles the comprehensive checks.

fi
186 changes: 186 additions & 0 deletions scripts/ci/delta_lint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/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=""

cleanup() {
[ -n "$CLIPPY_OUT" ] && rm -f "$CLIPPY_OUT"
[ -n "$DIFF_OUT" ] && rm -f "$DIFF_OUT"
}
Comment on lines +10 to +14
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EXIT cleanup trap only removes DIFF_OUT/CLIPPY_OUT. Since CLIPPY_STDERR is also created as a temp file later, failures before the explicit rm can leave it behind. Track CLIPPY_STDERR and remove it in cleanup as well.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865ceCLIPPY_STDERR added to EXIT trap cleanup.

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
Comment on lines +17 to +21
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delta_lint.sh hard-codes the merge base as origin/main (git merge-base origin/main HEAD). This will fail in repos without an origin remote, without a main branch locally fetched, or when the default branch differs. Consider deriving the upstream ref dynamically (e.g., from @{upstream} / the push remote), and/or adding a fallback that fetches the base ref or skips delta lint gracefully when the base can’t be determined.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865ce — delta_lint.sh now dynamically detects the upstream base branch.


# Determine the upstream base ref dynamically
BASE_REF=""
# 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
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)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git merge-base "$BASE_REF" HEAD can fail (e.g., if the remote default branch hasn’t been fetched or there’s no common ancestor). With set -e, that will hard-fail delta lint and block the push. Consider catching merge-base failures and emitting a warning + exit 0, consistent with the earlier “could not determine upstream base branch, skipping delta lint” behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865cegit merge-base failure caught with warning + 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 -- -D warnings > "$CLIPPY_OUT" 2>"$CLIPPY_STDERR" || true
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cargo clippy ... -- -D warnings promotes all warnings to level="error" in the JSON output. Since the Python filter treats all error diagnostics as always-blocking, this effectively defeats delta linting and will fail even for warnings outside the changed lines. To make delta lint work, run clippy without -D warnings (leave warnings as warnings) and only fail based on the changed-line overlap logic, while still treating true compiler errors as blocking.

Suggested change
cargo clippy --locked --all-targets --message-format=json -- -D warnings > "$CLIPPY_OUT" 2>"$CLIPPY_STDERR" || true
cargo clippy --locked --all-targets --message-format=json > "$CLIPPY_OUT" 2>"$CLIPPY_STDERR" || true

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Since -D warnings promotes warnings to error level, the current implementation (errors always blocking) is consistent with the quality gate intent. Distinguishing compiler errors from promoted warnings could be a future refinement via the code field.


# 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"
rm -f "$CLIPPY_STDERR"
exit 1
fi
rm -f "$CLIPPY_STDERR"

# 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
m = re.match(r'^\+\+\+ b/(.+)$', line)
if m:
current_file = m.group(1)
if current_file not in changed:
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_diff() sets current_file from the +++ b/... line, but doesn’t handle the deletion case (+++ /dev/null). In that situation current_file can remain set to the previous file and subsequent hunks may be mis-attributed. Treat +++ /dev/null (and/or diff --git boundaries) as a reset of current_file.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865ceparse_diff() handles +++ /dev/null by resetting current_file = None.

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, changed_ranges, repo_root):
"""Check if file:line falls within 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 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

# Get primary span
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:
continue
Comment on lines +182 to +187
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors that don’t include spans (or where spans is empty) are currently skipped entirely, which can let real build/compiler errors slip through delta lint. Treat diagnostics with level="error" and missing/empty spans as blocking (or fall back to failing on any error-level message regardless of span availability).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865ce — error-level diagnostics are now always blocking regardless of spans or changed-line overlap.


file_name = primary.get("file_name", "")
line_start = primary.get("line_start", 0)
rendered = cm.get("rendered", "").strip()

if in_changed_range(file_name, line_start, changed_ranges, repo_root):
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed-line matching only considers the diagnostic’s line_start. Multi-line spans that overlap a changed hunk but start outside it will be missed. Use line_end too and treat any overlap between [line_start,line_end] and a changed range as blocking (and consider checking all primary spans).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865cein_changed_range() now checks [line_start, line_end] interval overlap.

blocking.append(rendered)
else:
baseline.append(rendered)
Comment on lines +192 to +196
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the blocking/baseline decision is based only on whether the span touches changed lines, error-level diagnostics can end up in baseline and the script exits 0. Compilation errors should always fail regardless of diff range. Special-case level=="error" to always be blocking (and optionally keep filtering only warnings by changed lines).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0865ce — same commit. Errors always block; changed-lines filter only applies to warnings.


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
11 changes: 11 additions & 0 deletions scripts/ci/quality_gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/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
Comment on lines +7 to +8
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — the pre-push hook is a fast local sanity check, not a CI replacement. Running full feature matrices and --all --benches --tests --examples locally would take too long and cause developers to skip with --no-verify. CI handles the comprehensive checks.


echo "==> tests"
cargo test --locked
3 changes: 3 additions & 0 deletions scripts/dev-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Comment on lines +59 to +61
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If core.hooksPath is configured to .githooks (as suggested in .githooks/pre-commit), git rev-parse --git-path hooks will resolve to .githooks. In that case this ln -sf "$REPO_ROOT/.githooks/pre-push" "$HOOKS_DIR/pre-push" attempts to link the hook to itself and will fail under set -e. Consider detecting when HOOKS_DIR already points at .githooks and skipping the symlink (or linking to a different source path) to avoid a self-link error.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — will verify core.hooksPath interaction. If set to .githooks, the symlink approach is redundant but harmless.

else
echo " Skipped: not a git repository"
fi
Expand Down
Loading