summaryrefslogtreecommitdiff
path: root/.githooks
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-24 07:54:01 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-24 07:54:01 +0000
commitda942624a4abd82797f563cf8c57f1124c5de435 (patch)
treef7e4325a4783a77544e6ed1f0ac44ea844757fe8 /.githooks
parentb299d57d6cc240381aed51f91e5a26f1e633d78d (diff)
Add TDD quality gate: detekt, coverage tracking, pre-commit hook; fix theme crash risk
CLAUDE.md now documents mandatory red-green-refactor TDD, a per-commit quality gate (build, tests, lint, detekt, coverage-non-decrease, WebView-JS justification, manifest permission diff, secret scan), and why each check exists (tracing back to real bugs this audit found). Wires up detekt (static analysis) and a standalone Jacoco setup (scoped to data/readability/opml/work/stash -- ui/ Compose screens are impractical to unit-test for coverage) via app/build.gradle.kts. Bumped Kotlin/KSP to 2.0.21/2.0.21-1.0.28 to match detekt 1.23.8's compatibility requirement. Also fixes the themes.xml bug the lint gate immediately caught: Theme.FeedReader's parent required API 29 while minSdk is 26 -- a real crash risk on Android 8.0-9.0 devices. Swapped to the plain (non-DayNight) DeviceDefault parent, since Compose already owns all real theming via a hardcoded darkColorScheme() regardless of system day/night state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
Diffstat (limited to '.githooks')
-rwxr-xr-x.githooks/pre-commit94
1 files changed, 94 insertions, 0 deletions
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..389280a
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,94 @@
+#!/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/8] Build =="
+./gradlew :app:compileDebugKotlin -q
+
+echo "== [2/8] Tests =="
+./gradlew :app:testDebugUnitTest -q
+
+echo "== [3/8] 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/8] 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/8] Coverage must not decrease =="
+./gradlew :app:jacocoTestReport -q
+REPORT="app/build/reports/jacoco/report.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/8] 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/8] 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/8] 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 "pre-commit: all checks passed."