#!/usr/bin/env bash # Quality gate -- see CLAUDE.md "Quality gate -- every commit" for what each step is and why. # Activate once per clone with: git config core.hooksPath .githooks set -euo pipefail cd "$(git rev-parse --show-toplevel)" fail() { echo "pre-commit: $1" >&2 exit 1 } # The server first, because it is the fast one: ten seconds against several minutes, so a Go # mistake is reported before the Android build has begun rather than after it. # # It had no gate at all until now. Twenty-five server commits went in on a human remembering to # type "go test" and reading the output -- the exact habit the Gradle wrappers exist to replace. echo "== [1/10] Server: format, build, vet, tests ==" if [ -f server/go.mod ]; then if ! /workspace/devtools/bin/go-verify server; then fail "the server did not pass -- see the report above." fi else echo "pre-commit: no server module here, skipping." fi echo "== [2/10] Build ==" ./gradlew :app:compileDebugKotlin -q echo "== [3/10] Tests ==" ./gradlew :app:testDebugUnitTest -q echo "== [4/10] Static analysis: lint ==" if ! ./gradlew :app:lintDebug -q; then fail "lint failed -- fix all lint ERRORS before committing (warnings are fine to accumulate, see CLAUDE.md)." fi echo "== [5/10] Static analysis: detekt (code-quality auditor) ==" if ! ./gradlew :app:detekt -q; then fail "detekt found new issues beyond the tracked baseline. Fix them, or if they're genuinely pre-existing debt being touched incidentally, regenerate the baseline with './gradlew :app:detektBaseline' and explain why in the commit message." fi echo "== [6/10] Coverage must not decrease ==" ./gradlew :app:jacocoTestReport -q REPORT="app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" BASELINE_FILE=".coverage-baseline" if [ -f "$REPORT" ]; then CURRENT=$(python3 - "$REPORT" <<'PY' import sys, xml.etree.ElementTree as ET root = ET.parse(sys.argv[1]).getroot() line = next(c for c in root.findall('counter') if c.get('type') == 'LINE') missed, covered = int(line.get('missed')), int(line.get('covered')) total = missed + covered print(f"{(covered / total * 100) if total else 0.0:.2f}") PY ) else CURRENT="0.00" fi BASELINE=$(cat "$BASELINE_FILE" 2>/dev/null || echo "0.00") DROPPED=$(python3 -c "print(1 if float('$CURRENT') < float('$BASELINE') else 0)") if [ "$DROPPED" = "1" ]; then if [ -n "${COVERAGE_EXCEPTION:-}" ]; then echo "pre-commit: coverage dropped ${BASELINE}% -> ${CURRENT}% -- allowed via COVERAGE_EXCEPTION: ${COVERAGE_EXCEPTION}" echo "$CURRENT" > "$BASELINE_FILE" git add "$BASELINE_FILE" else fail "coverage dropped ${BASELINE}% -> ${CURRENT}%. If this drop is the correct outcome (e.g. deleting dead covered code), commit with COVERAGE_EXCEPTION=\"reason\" git commit ... Otherwise, add tests." fi else echo "$CURRENT" > "$BASELINE_FILE" git add "$BASELINE_FILE" echo "pre-commit: coverage ${BASELINE}% -> ${CURRENT}%" fi echo "== [7/10] WebView JavaScript justification ==" STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM -- '*.kt' || true) if [ -n "$STAGED_KT" ]; then for f in $STAGED_KT; do [ -f "$f" ] || continue ADDED_JS_LINES=$(git diff --cached -U0 -- "$f" | grep -E '^\+.*(javaScriptEnabled\s*=\s*true|setJavaScriptEnabled\(true\))' || true) if [ -n "$ADDED_JS_LINES" ]; then if ! grep -q '@SuppressLint("SetJavaScriptEnabled")' "$f"; then fail "$f enables JavaScript on a WebView with no @SuppressLint(\"SetJavaScriptEnabled\") + justification comment nearby (see ReaderExtractor.kt, LinkReaderScreen.kt, BrowserScreen.kt for the pattern). This app's security posture depends on every JS-enabled site being deliberate and explained." fi fi done fi echo "== [8/10] Manifest permission diff ==" if git diff --cached --name-only | grep -q '^app/src/main/AndroidManifest.xml$'; then if git diff --cached -- app/src/main/AndroidManifest.xml | grep -qE '^\+.*. Make sure the commit message says why." fi fi echo "== [9/10] Secret scan ==" if [ -z "${SECRET_SCAN_OVERRIDE:-}" ]; then SECRET_HIT=$(git diff --cached -U0 -- . ':!*.lock' | grep -E '^\+' | grep -viE '^\+\+\+' | grep -E '(api[_-]?key|secret|password|token)["'\'' ]*[:=]["'\'' ]*[A-Za-z0-9_\-]{16,}' || true) if [ -n "$SECRET_HIT" ]; then echo "$SECRET_HIT" >&2 fail "possible hardcoded secret in the staged diff (see above). If this is a false positive (e.g. an obviously-fake test fixture key), commit with SECRET_SCAN_OVERRIDE=1 git commit ..." fi else echo "pre-commit: secret scan skipped (SECRET_SCAN_OVERRIDE set)" fi echo "== [10/10] Exported Room schemas are append-only ==" # A schema JSON already committed describes a database version that exists on a phone. Room # rewrites the file for whatever version the @Database annotation declares, so editing an entity # before bumping that number silently overwrites the record of the old shape -- and the migration # test then validates happily against a schema that never shipped. It happened twice building # Phase 2, and a note in CLAUDE.md did not prevent the second time. SCHEMA_EDITS=$(git diff --cached --name-only --diff-filter=M -- 'app/schemas/**/*.json' || true) if [ -n "$SCHEMA_EDITS" ]; then echo "$SCHEMA_EDITS" >&2 fail "an already-committed Room schema was modified (see above). Bump the database version first so a new file is written, then restore these with: git checkout -- app/schemas/" fi echo "pre-commit: all checks passed."