#!/usr/bin/env bash # gradle-report [--allow-stale] [-- ] # # Runs a static-analysis task and reports from its XML report, refusing to speak if the report # is older than the run. # # Exists because detekt has a failure mode that looks exactly like findings: an invalid config # property makes it abort BEFORE analysing anything, leaving the previous run's XML on disk. # Three times in one session that stale file was read as the current result, and the line # numbers never moved because the analysis never happened. The only honest signal was the exit # code, which said "failed" while the report said "here are your 8 findings". set -uo pipefail die() { echo "gradle-report: $1" >&2; exit 2; } [ $# -ge 2 ] || die "usage: gradle-report [-- ]" PROJECT_DIR=$(cd "$1" 2>/dev/null && pwd) || die "no such project directory: $1" KIND="$2" shift 2 ALLOW_STALE=0 if [ "${1:-}" = "--allow-stale" ]; then ALLOW_STALE=1; shift; fi [ "${1:-}" = "--" ] && shift EXTRA=("$@") case "$KIND" in detekt) TASK=":app:detekt"; REPORT="$PROJECT_DIR/app/build/reports/detekt/detekt.xml" ;; # AGP has moved this file between versions, so locate it rather than assuming. A hardcoded # path that stops resolving turns into "MISSING ... PASS", which is worse than no check. lint) TASK=":app:lintDebug" REPORT=$(find "$PROJECT_DIR/app/build" -name "lint-results-debug.xml" 2>/dev/null | head -1) REPORT=${REPORT:-$PROJECT_DIR/app/build/reports/lint-results-debug.xml} ;; *) die "kind must be 'detekt' or 'lint', got '$KIND'" ;; esac [ -x "$PROJECT_DIR/gradlew" ] || die "no gradlew in $PROJECT_DIR" # Marker predates the run, so any report not newer than it is stale. MARKER=$(mktemp) trap 'rm -f "$MARKER"' EXIT sleep 1 LOG=$(mktemp) ( cd "$PROJECT_DIR" && ./gradlew "$TASK" "${EXTRA[@]}" ) >"$LOG" 2>&1 STATUS=$? echo "=== gradle-report ($KIND) ===" echo "project : $PROJECT_DIR" echo "task : $TASK" echo "exit : $STATUS" # An invalid config aborts detekt before it analyses anything. Name it, because the report on # disk will be the previous run's and will look perfectly reasonable. if grep -qiE "is misspelled or does not exist|configuration file is invalid|invalid config" "$LOG"; then echo echo "CONFIG INVALID — analysis never ran. Offending properties:" grep -iE "is misspelled or does not exist|configuration file is invalid|invalid config" "$LOG" | sed 's/^/ /' | sort -u echo echo "result : FAIL (config rejected; any report on disk is from an earlier run)" rm -f "$LOG" exit 1 fi if [ ! -f "$REPORT" ]; then # Falling back to the exit code here is how a broken path becomes a silent pass. Say that # the check did not actually happen. echo "report : MISSING ($REPORT)" echo "hint : the report path may have moved; this check did NOT inspect any findings" echo "result : FAIL — no report to read (gradle exit $STATUS)" rm -f "$LOG" exit 1 fi if [ "$REPORT" -ot "$MARKER" ] && [ $ALLOW_STALE -eq 0 ]; then echo "report : STALE — $REPORT predates this run" if grep -qE "Task :app:(detekt|lintDebug) UP-TO-DATE" "$LOG"; then echo "hint : Gradle treated $TASK as UP-TO-DATE, so nothing was re-analysed and the" echo " report on disk describes an earlier state of the code." echo " Re-run with: -- --rerun-tasks (or --allow-stale to accept it)" else echo "hint : the task ran but produced no new report. Re-run with: -- --rerun-tasks" fi echo "result : FAIL — refusing to report numbers that describe older code" rm -f "$LOG" exit 1 fi FINDINGS=$(python3 - "$REPORT" "$KIND" <<'PY' import sys, xml.etree.ElementTree as ET path, kind = sys.argv[1], sys.argv[2] try: root = ET.parse(path).getroot() except Exception as e: print(f"UNREADABLE {e}") raise SystemExit rows = [] if kind == "detekt": for f in root.iter("file"): for err in f.iter("error"): rows.append(f"{f.get('name','').split('/')[-1]}:{err.get('line')} " f"[{(err.get('source') or '').split('.')[-1]}] {(err.get('message') or '')[:90]}") else: for issue in root.iter("issue"): if issue.get("severity", "").lower() not in ("error", "fatal"): continue loc = next(issue.iter("location"), None) where = f"{(loc.get('file','') if loc is not None else '').split('/')[-1]}:{loc.get('line') if loc is not None else '?'}" rows.append(f"{where} [{issue.get('id')}] {(issue.get('message') or '')[:90]}") print(f"COUNT {len(rows)}") for r in rows: print(f" {r}") PY ) if [ "$REPORT" -ot "$MARKER" ]; then echo "report : STALE (accepted via --allow-stale)" else echo "report : fresh" fi echo "$FINDINGS" | head -1 | sed 's/^COUNT /findings: /' echo "$FINDINGS" | tail -n +2 COUNT=$(echo "$FINDINGS" | head -1 | awk '{print $2}') if [ "$COUNT" = "0" ] && [ $STATUS -eq 0 ]; then echo "result : PASS (0 findings, gradle exit 0)" else echo "result : FAIL (${COUNT:-?} findings, gradle exit $STATUS)" fi rm -f "$LOG" [ $STATUS -eq 0 ] && [ "$COUNT" = "0" ]