diff --git a/.githooks/pre-push b/.githooks/pre-push index 979e4d9..f69e1cb 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -20,6 +20,14 @@ if [ "${ZEROCLAW_STRICT_LINT:-0}" = "1" ]; then } fi +if [ "${ZEROCLAW_STRICT_DELTA_LINT:-0}" = "1" ]; then + echo "==> pre-push: running strict delta lint gate (ZEROCLAW_STRICT_DELTA_LINT=1)..." + ./scripts/ci/rust_strict_delta_gate.sh || { + echo "FAIL: strict delta lint gate reported issues." + exit 1 + } +fi + if [ "${ZEROCLAW_DOCS_LINT:-0}" = "1" ]; then echo "==> pre-push: running docs quality gate (ZEROCLAW_DOCS_LINT=1)..." ./scripts/ci/docs_quality_gate.sh || { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de5d5ff..d4fbd33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,26 @@ jobs: - name: Run rust quality gate run: ./scripts/ci/rust_quality_gate.sh + lint-strict-delta: + name: Lint Strict Delta + needs: [changes] + if: needs.changes.outputs.rust_changed == 'true' + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 25 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.92.0 + components: clippy + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Run strict lint delta gate + env: + BASE_SHA: ${{ needs.changes.outputs.base_sha }} + run: ./scripts/ci/rust_strict_delta_gate.sh + test: name: Test needs: [changes] @@ -243,7 +263,7 @@ jobs: ci-required: name: CI Required Gate if: always() - needs: [changes, lint, test, build, docs-only, non-rust, docs-quality] + needs: [changes, lint, lint-strict-delta, test, build, docs-only, non-rust, docs-quality] runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Enforce required status @@ -277,15 +297,17 @@ jobs: fi lint_result="${{ needs.lint.result }}" + lint_strict_delta_result="${{ needs.lint-strict-delta.result }}" test_result="${{ needs.test.result }}" build_result="${{ needs.build.result }}" echo "lint=${lint_result}" + echo "lint_strict_delta=${lint_strict_delta_result}" echo "test=${test_result}" echo "build=${build_result}" echo "docs=${docs_result}" - if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then + if [ "$lint_result" != "success" ] || [ "$lint_strict_delta_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then echo "Required CI jobs did not pass." exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd398e9..a25ad4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,12 @@ cargo test --locked # Format & lint (required before PR) ./scripts/ci/rust_quality_gate.sh -# Optional strict lint audit (recommended periodically) +# Optional strict lint audit (full repo, recommended periodically) ./scripts/ci/rust_quality_gate.sh --strict +# Optional strict lint delta gate (blocks only changed Rust lines) +./scripts/ci/rust_strict_delta_gate.sh + # Optional docs lint gate (blocks only markdown issues on changed lines) ./scripts/ci/docs_quality_gate.sh @@ -44,6 +47,12 @@ For an opt-in strict lint pass during pre-push, set: ZEROCLAW_STRICT_LINT=1 git push ``` +For an opt-in strict lint delta pass during pre-push (changed Rust lines only), set: + +```bash +ZEROCLAW_STRICT_DELTA_LINT=1 git push +``` + For an opt-in docs quality pass during pre-push (changed-line markdown gate), set: ```bash @@ -359,7 +368,8 @@ impl Tool for YourTool { - [ ] PR template sections are completed (including security + rollback) - [ ] `./scripts/ci/rust_quality_gate.sh` — merge gate formatter/lint baseline passes - [ ] `cargo test --locked` — all tests pass locally or skipped tests are explained -- [ ] Optional strict audit: `./scripts/ci/rust_quality_gate.sh --strict` (run when doing lint cleanup or before release-hardening work) +- [ ] Optional strict audit: `./scripts/ci/rust_quality_gate.sh --strict` (full repo, run when doing lint cleanup or release-hardening work) +- [ ] Optional strict delta audit: `./scripts/ci/rust_strict_delta_gate.sh` (changed Rust lines only, useful for incremental debt control) - [ ] New code has inline `#[cfg(test)]` tests - [ ] No new dependencies unless absolutely necessary (we optimize for binary size) - [ ] README updated if adding user-facing features diff --git a/dev/README.md b/dev/README.md index c3b47c0..12fcb4b 100644 --- a/dev/README.md +++ b/dev/README.md @@ -115,10 +115,17 @@ To run an opt-in strict lint audit locally: ./dev/ci.sh lint-strict ``` +To run the incremental strict gate (changed Rust lines only): + +```bash +./dev/ci.sh lint-delta +``` + ### 3. Run targeted stages ```bash ./dev/ci.sh lint +./dev/ci.sh lint-delta ./dev/ci.sh test ./dev/ci.sh build ./dev/ci.sh deny diff --git a/dev/ci.sh b/dev/ci.sh index 91bf4ee..61bf73b 100755 --- a/dev/ci.sh +++ b/dev/ci.sh @@ -28,6 +28,7 @@ Commands: shell Open an interactive shell inside the CI container lint Run rustfmt + clippy correctness gate (container only) lint-strict Run rustfmt + full clippy warnings gate (container only) + lint-delta Run strict lint delta gate on changed Rust lines (container only) test Run cargo test (container only) build Run release build smoke check (container only) audit Run cargo audit (container only) @@ -61,6 +62,10 @@ case "$1" in run_in_ci "./scripts/ci/rust_quality_gate.sh --strict" ;; + lint-delta) + run_in_ci "./scripts/ci/rust_strict_delta_gate.sh" + ;; + test) run_in_ci "cargo test --locked --verbose" ;; diff --git a/docs/ci-map.md b/docs/ci-map.md index 77f68e3..007d6fd 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -9,7 +9,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u ### Merge-Blocking - `.github/workflows/ci.yml` (`CI`) - - Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines) + - Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines) - Merge gate: `CI Required Gate` - `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`) - Purpose: lint GitHub workflow files (`actionlint`, tab checks) @@ -71,12 +71,14 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u 4. Security failures: inspect `.github/workflows/security.yml` and `deny.toml`. 5. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`. 6. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci.yml`. +7. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope. ## Maintenance Rules - Keep merge-blocking checks deterministic and reproducible (`--locked` where applicable). -- Keep merge-blocking rust quality policy aligned across `.github/workflows/ci.yml`, `dev/ci.sh`, and `.githooks/pre-push` (`./scripts/ci/rust_quality_gate.sh`). -- Run strict lint audits regularly via `./scripts/ci/rust_quality_gate.sh --strict` (for example through `./dev/ci.sh lint-strict`) and track cleanup in focused PRs. +- Keep merge-blocking rust quality policy aligned across `.github/workflows/ci.yml`, `dev/ci.sh`, and `.githooks/pre-push` (`./scripts/ci/rust_quality_gate.sh` + `./scripts/ci/rust_strict_delta_gate.sh`). +- Use `./scripts/ci/rust_strict_delta_gate.sh` (or `./dev/ci.sh lint-delta`) as the incremental strict merge gate for changed Rust lines. +- Run full strict lint audits regularly via `./scripts/ci/rust_quality_gate.sh --strict` (for example through `./dev/ci.sh lint-strict`) and track cleanup in focused PRs. - Keep docs markdown gating incremental via `./scripts/ci/docs_quality_gate.sh` (block changed-line issues, report baseline issues separately). - Keep docs link gating incremental via `./scripts/ci/collect_changed_links.py` + lychee (check only links added on changed lines). - Prefer explicit workflow permissions (least privilege). diff --git a/scripts/ci/rust_strict_delta_gate.sh b/scripts/ci/rust_strict_delta_gate.sh new file mode 100755 index 0000000..81da507 --- /dev/null +++ b/scripts/ci/rust_strict_delta_gate.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_SHA="${BASE_SHA:-}" +RUST_FILES_RAW="${RUST_FILES:-}" + +if [ -z "$BASE_SHA" ] && git rev-parse --verify origin/main >/dev/null 2>&1; then + BASE_SHA="$(git merge-base origin/main HEAD)" +fi + +if [ -z "$BASE_SHA" ] && git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + BASE_SHA="$(git rev-parse HEAD~1)" +fi + +if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + echo "BASE_SHA is missing or invalid for strict delta gate." + echo "Set BASE_SHA explicitly or ensure origin/main is available." + exit 1 +fi + +if [ -z "$RUST_FILES_RAW" ]; then + RUST_FILES_RAW="$(git diff --name-only "$BASE_SHA" HEAD | awk '/\.rs$/ { print }')" +fi + +ALL_FILES=() +while IFS= read -r file; do + if [ -n "$file" ]; then + ALL_FILES+=("$file") + fi +done < <(printf '%s\n' "$RUST_FILES_RAW") + +if [ "${#ALL_FILES[@]}" -eq 0 ]; then + echo "No Rust source files changed; skipping strict delta gate." + exit 0 +fi + +EXISTING_FILES=() +for file in "${ALL_FILES[@]}"; do + if [ -f "$file" ]; then + EXISTING_FILES+=("$file") + fi +done + +if [ "${#EXISTING_FILES[@]}" -eq 0 ]; then + echo "No existing changed Rust files to lint; skipping strict delta gate." + exit 0 +fi + +echo "Strict delta linting changed Rust files: ${EXISTING_FILES[*]}" + +CHANGED_LINES_JSON_FILE="$(mktemp)" +CLIPPY_JSON_FILE="$(mktemp)" +CLIPPY_STDERR_FILE="$(mktemp)" +FILTERED_OUTPUT_FILE="$(mktemp)" +trap 'rm -f "$CHANGED_LINES_JSON_FILE" "$CLIPPY_JSON_FILE" "$CLIPPY_STDERR_FILE" "$FILTERED_OUTPUT_FILE"' EXIT + +python3 - "$BASE_SHA" "${EXISTING_FILES[@]}" >"$CHANGED_LINES_JSON_FILE" <<'PY' +import json +import re +import subprocess +import sys + +base = sys.argv[1] +files = sys.argv[2:] +hunk = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") +changed = {} + +for path in files: + proc = subprocess.run( + ["git", "diff", "--unified=0", base, "HEAD", "--", path], + check=False, + capture_output=True, + text=True, + ) + ranges = [] + for line in proc.stdout.splitlines(): + match = hunk.match(line) + if not match: + continue + start = int(match.group(1)) + count = int(match.group(2) or "1") + if count > 0: + ranges.append([start, start + count - 1]) + changed[path] = ranges + +print(json.dumps(changed)) +PY + +set +e +cargo clippy --quiet --locked --all-targets --message-format=json -- -D warnings >"$CLIPPY_JSON_FILE" 2>"$CLIPPY_STDERR_FILE" +CLIPPY_EXIT=$? +set -e + +if [ "$CLIPPY_EXIT" -eq 0 ]; then + echo "Strict delta gate passed: no strict warnings/errors." + exit 0 +fi + +set +e +python3 - "$CLIPPY_JSON_FILE" "$CHANGED_LINES_JSON_FILE" >"$FILTERED_OUTPUT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +messages_file = sys.argv[1] +changed_file = sys.argv[2] + +with open(changed_file, "r", encoding="utf-8") as f: + changed = json.load(f) + +cwd = Path.cwd().resolve() + + +def normalize_path(path_value: str) -> str: + path = Path(path_value) + if path.is_absolute(): + try: + return path.resolve().relative_to(cwd).as_posix() + except Exception: + return path.as_posix() + return path.as_posix() + + +blocking = [] +baseline = [] +unclassified = [] +classified_count = 0 + +with open(messages_file, "r", encoding="utf-8", errors="ignore") as f: + for raw_line in f: + line = raw_line.strip() + if not line: + continue + + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + + if payload.get("reason") != "compiler-message": + continue + + message = payload.get("message", {}) + level = message.get("level") + if level not in {"warning", "error"}: + continue + + code_obj = message.get("code") or {} + code = code_obj.get("code") if isinstance(code_obj, dict) else None + text = message.get("message", "") + spans = message.get("spans") or [] + + candidate_spans = [span for span in spans if span.get("is_primary")] + if not candidate_spans: + candidate_spans = spans + + span_entries = [] + for span in candidate_spans: + file_name = span.get("file_name") + line_start = span.get("line_start") + line_end = span.get("line_end") + if not file_name or line_start is None: + continue + norm_path = normalize_path(file_name) + span_entries.append((norm_path, int(line_start), int(line_end or line_start))) + + if not span_entries: + unclassified.append(f"{level.upper()} {code or '-'} {text}") + continue + + is_changed_line = False + best_path, best_line, _ = span_entries[0] + for path, line_start, line_end in span_entries: + ranges = changed.get(path) + if ranges is None: + continue + + if not ranges: + is_changed_line = True + best_path, best_line = path, line_start + break + + for start, end in ranges: + if line_end >= start and line_start <= end: + is_changed_line = True + best_path, best_line = path, line_start + break + if is_changed_line: + break + + entry = f"{best_path}:{best_line} {level.upper()} {code or '-'} {text}" + classified_count += 1 + if is_changed_line: + blocking.append(entry) + else: + baseline.append(entry) + +if baseline: + print("Existing strict lint issues outside changed Rust lines (non-blocking):") + for entry in baseline: + print(f" - {entry}") + +if blocking: + print("Strict lint issues introduced on changed Rust lines (blocking):") + for entry in blocking: + print(f" - {entry}") + print(f"Blocking strict lint issues: {len(blocking)}") + sys.exit(1) + +if classified_count > 0: + print("No blocking strict lint issues on changed Rust lines.") + sys.exit(0) + +if unclassified: + print("Strict lint exited non-zero with unclassified diagnostics; failing safe:") + for entry in unclassified[:20]: + print(f" - {entry}") + sys.exit(2) + +print("Strict lint exited non-zero without parsable diagnostics; failing safe.") +sys.exit(2) +PY +FILTER_EXIT=$? +set -e + +cat "$FILTERED_OUTPUT_FILE" + +if [ "$FILTER_EXIT" -eq 0 ]; then + if [ -s "$CLIPPY_STDERR_FILE" ]; then + echo "clippy stderr summary (informational):" + cat "$CLIPPY_STDERR_FILE" + fi + exit 0 +fi + +if [ -s "$CLIPPY_STDERR_FILE" ]; then + echo "clippy stderr summary:" + cat "$CLIPPY_STDERR_FILE" +fi + +exit "$FILTER_EXIT"