ci: add strict delta lint gate for changed rust lines
This commit is contained in:
parent
6e855cdcf1
commit
b81e4c6c50
7 changed files with 303 additions and 7 deletions
242
scripts/ci/rust_strict_delta_gate.sh
Executable file
242
scripts/ci/rust_strict_delta_gate.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue