ci: add strict delta lint gate for changed rust lines

This commit is contained in:
Chummy 2026-02-17 15:12:48 +08:00
parent 6e855cdcf1
commit b81e4c6c50
7 changed files with 303 additions and 7 deletions

View file

@ -20,6 +20,14 @@ if [ "${ZEROCLAW_STRICT_LINT:-0}" = "1" ]; then
} }
fi 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 if [ "${ZEROCLAW_DOCS_LINT:-0}" = "1" ]; then
echo "==> pre-push: running docs quality gate (ZEROCLAW_DOCS_LINT=1)..." echo "==> pre-push: running docs quality gate (ZEROCLAW_DOCS_LINT=1)..."
./scripts/ci/docs_quality_gate.sh || { ./scripts/ci/docs_quality_gate.sh || {

View file

@ -136,6 +136,26 @@ jobs:
- name: Run rust quality gate - name: Run rust quality gate
run: ./scripts/ci/rust_quality_gate.sh 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: test:
name: Test name: Test
needs: [changes] needs: [changes]
@ -243,7 +263,7 @@ jobs:
ci-required: ci-required:
name: CI Required Gate name: CI Required Gate
if: always() 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 runs-on: blacksmith-2vcpu-ubuntu-2404
steps: steps:
- name: Enforce required status - name: Enforce required status
@ -277,15 +297,17 @@ jobs:
fi fi
lint_result="${{ needs.lint.result }}" lint_result="${{ needs.lint.result }}"
lint_strict_delta_result="${{ needs.lint-strict-delta.result }}"
test_result="${{ needs.test.result }}" test_result="${{ needs.test.result }}"
build_result="${{ needs.build.result }}" build_result="${{ needs.build.result }}"
echo "lint=${lint_result}" echo "lint=${lint_result}"
echo "lint_strict_delta=${lint_strict_delta_result}"
echo "test=${test_result}" echo "test=${test_result}"
echo "build=${build_result}" echo "build=${build_result}"
echo "docs=${docs_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." echo "Required CI jobs did not pass."
exit 1 exit 1
fi fi

View file

@ -21,9 +21,12 @@ cargo test --locked
# Format & lint (required before PR) # Format & lint (required before PR)
./scripts/ci/rust_quality_gate.sh ./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 ./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) # Optional docs lint gate (blocks only markdown issues on changed lines)
./scripts/ci/docs_quality_gate.sh ./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 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: For an opt-in docs quality pass during pre-push (changed-line markdown gate), set:
```bash ```bash
@ -359,7 +368,8 @@ impl Tool for YourTool {
- [ ] PR template sections are completed (including security + rollback) - [ ] PR template sections are completed (including security + rollback)
- [ ] `./scripts/ci/rust_quality_gate.sh` — merge gate formatter/lint baseline passes - [ ] `./scripts/ci/rust_quality_gate.sh` — merge gate formatter/lint baseline passes
- [ ] `cargo test --locked` — all tests pass locally or skipped tests are explained - [ ] `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 - [ ] New code has inline `#[cfg(test)]` tests
- [ ] No new dependencies unless absolutely necessary (we optimize for binary size) - [ ] No new dependencies unless absolutely necessary (we optimize for binary size)
- [ ] README updated if adding user-facing features - [ ] README updated if adding user-facing features

View file

@ -115,10 +115,17 @@ To run an opt-in strict lint audit locally:
./dev/ci.sh lint-strict ./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 ### 3. Run targeted stages
```bash ```bash
./dev/ci.sh lint ./dev/ci.sh lint
./dev/ci.sh lint-delta
./dev/ci.sh test ./dev/ci.sh test
./dev/ci.sh build ./dev/ci.sh build
./dev/ci.sh deny ./dev/ci.sh deny

View file

@ -28,6 +28,7 @@ Commands:
shell Open an interactive shell inside the CI container shell Open an interactive shell inside the CI container
lint Run rustfmt + clippy correctness gate (container only) lint Run rustfmt + clippy correctness gate (container only)
lint-strict Run rustfmt + full clippy warnings 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) test Run cargo test (container only)
build Run release build smoke check (container only) build Run release build smoke check (container only)
audit Run cargo audit (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" run_in_ci "./scripts/ci/rust_quality_gate.sh --strict"
;; ;;
lint-delta)
run_in_ci "./scripts/ci/rust_strict_delta_gate.sh"
;;
test) test)
run_in_ci "cargo test --locked --verbose" run_in_ci "cargo test --locked --verbose"
;; ;;

View file

@ -9,7 +9,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
### Merge-Blocking ### Merge-Blocking
- `.github/workflows/ci.yml` (`CI`) - `.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` - Merge gate: `CI Required Gate`
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`) - `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
- Purpose: lint GitHub workflow files (`actionlint`, tab checks) - 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`. 4. Security failures: inspect `.github/workflows/security.yml` and `deny.toml`.
5. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`. 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`. 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 ## Maintenance Rules
- Keep merge-blocking checks deterministic and reproducible (`--locked` where applicable). - 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`). - 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`).
- 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. - 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 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). - 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). - Prefer explicit workflow permissions (least privilege).

View file

@ -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"