summaryrefslogtreecommitdiff
path: root/.githooks/pre-commit
blob: 4a3bcc5856ece722e18c1e5a9a64a6a306d7ed44 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#!/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
}

echo "== [1/9] Build =="
./gradlew :app:compileDebugKotlin -q

echo "== [2/9] Tests =="
./gradlew :app:testDebugUnitTest -q

echo "== [3/9] 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 "== [4/9] 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 "== [5/9] 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 "== [6/9] 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 "== [7/9] 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 '^\+.*<uses-permission'; then
        echo "pre-commit: WARNING -- AndroidManifest.xml adds a new <uses-permission>. Make sure the commit message says why."
    fi
fi

echo "== [8/9] 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 "== [9/9] 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."