diff --git a/.claude/hooks/post-edit-check.sh b/.claude/hooks/post-edit-check.sh new file mode 100755 index 0000000..8462169 --- /dev/null +++ b/.claude/hooks/post-edit-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# PostToolUse check for Edit and Write. Catches the edits that build clean and fail only at runtime. +set -euo pipefail + +path=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty') +[ -n "$path" ] && [ -f "$path" ] || exit 0 + +case "$path" in + */composeResources/drawable*/*.xml) + # CMP's vector parser resolves neither; both crash at runtime with a clean build. + if hits=$(grep -nE '\?attr/|@android:' "$path"); then + jq -n --arg r "$path uses ?attr/ or @android: references, which Compose Multiplatform cannot resolve (runtime crash, clean build). Use literal colours such as #FFFFFFFF and let Icon tint from LocalContentColor. Offending lines: +$hits" '{decision: "block", reason: $r}' + fi + ;; + */web/src/commonMain/resources/styles.css | */ui/theme/WebSkin.kt) + # The page colour is duplicated so it paints before any Kotlin runs. + jq -n '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: "styles.css duplicates the page colour from WebSkin.kt (it paints before any Kotlin runs). If you changed that colour, change it in the other file too, or every cold load flashes the wrong colour."}}' + ;; +esac +exit 0 diff --git a/.claude/hooks/pre-commit-ktfmt.sh b/.claude/hooks/pre-commit-ktfmt.sh new file mode 100755 index 0000000..188a9e7 --- /dev/null +++ b/.claude/hooks/pre-commit-ktfmt.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# PreToolUse on `git commit`: CI runs ktfmtCheck, so fail the commit here instead of a CI round trip. +# Only runs Gradle when Kotlin sources are staged. +set -uo pipefail + +cd "$CLAUDE_PROJECT_DIR" || exit 0 +staged=$(git diff --cached --name-only --diff-filter=ACMR) +grep -Eq '\.kts?$' <<<"$staged" || exit 0 + +failed=() +if grep -Ev '^build-logic/' <<<"$staged" | grep -Eq '\.kts?$'; then + ./gradlew -q ktfmtCheck >/dev/null 2>&1 || failed+=("./gradlew ktfmtFormat") +fi +if grep -Eq '^build-logic/.*\.kts?$' <<<"$staged"; then + ./gradlew -q -p build-logic ktfmtCheck >/dev/null 2>&1 || failed+=("./gradlew -p build-logic ktfmtFormat") +fi + +if [ ${#failed[@]} -gt 0 ]; then + jq -n --arg r "ktfmtCheck failed on staged Kotlin. Run: ${failed[*]}, then re-stage the files by path and commit again." \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}' +fi +exit 0 diff --git a/.claude/hooks/pre-tool-guard.sh b/.claude/hooks/pre-tool-guard.sh new file mode 100755 index 0000000..9958a00 --- /dev/null +++ b/.claude/hooks/pre-tool-guard.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# PreToolUse guard for Bash, Edit and Write. Denies the operations this repo documents as never-do, +# each of which fails later and somewhere else rather than at the point of the mistake. +set -euo pipefail + +input=$(cat) +tool=$(jq -r '.tool_name // empty' <<<"$input") + +deny() { + jq -n --arg r "$1" \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}' + exit 0 +} + +case "$tool" in + Bash) + cmd=$(jq -r '.tool_input.command // empty' <<<"$input") + # `git add -A` / `.` / `--all` sweeps Xcode xcuserdata/ on branches below the iOS PR's gitignore. + if grep -Eq '(^|[;&|[:space:]])git[[:space:]]+add([[:space:]]+[^;&|]*)?[[:space:]](-A|--all|\.)([[:space:]]|$|[;&|])' <<<"$cmd"; then + deny "Stage by explicit path, never 'git add -A', '--all' or '.': on some branches the sweep picks up Xcode xcuserdata/. Check 'git status --short' first." + fi + # Stacked PRs: a bare force push can clobber a rebase done elsewhere. + if grep -Eq '(^|[;&|[:space:]])git[[:space:]]+push([[:space:]]|$)' <<<"$cmd" && + grep -Eq '[[:space:]](--force|-f)([[:space:]]|$)' <<<"$cmd" && + ! grep -q -- '--force-with-lease' <<<"$cmd"; then + deny "Use 'git push --force-with-lease', never a bare --force or -f." + fi + ;; + Edit | Write | MultiEdit) + path=$(jq -r '.tool_input.file_path // empty' <<<"$input") + case "$path" in + */build/*) + deny "$path is build output (Apollo generated code included). Change the source or the .graphql operation instead." ;; + */kotlin-js-store/*.lock) + deny "Never hand-edit the npm lockfiles. Regenerate both with './gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock'." ;; + esac + ;; +esac +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index fde9230..575f9d3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -26,5 +26,43 @@ "Bash(adb shell pidof *)", "Bash(plutil -p *)" ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-tool-guard.sh", + "timeout": 10 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git commit *)", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-commit-ktfmt.sh", + "timeout": 600, + "statusMessage": "Checking ktfmt on staged Kotlin…" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-check.sh", + "timeout": 10 + } + ] + } + ] } } diff --git a/.gitignore b/.gitignore index ac7bb51..6412274 100644 --- a/.gitignore +++ b/.gitignore @@ -18,13 +18,14 @@ xcuserdata/ *.xcscmblueprint .swiftpm/ -# Claude Code. `rules/`, `skills/`, `agents/` and `settings.json` are project documentation and -# configuration and are committed — the rules load by path and hold the invariants that fail -# silently, AGENTS.md routes to the skills by task, the agents encode this build's traps and output -# contracts, and all four are reviewable in a PR like any other doc. Everything else -# here — `settings.local.json` above all — is per-user state. +# Claude Code. `rules/`, `hooks/`, `skills/`, `agents/` and `settings.json` are project documentation +# and configuration and are committed — the rules load by path and hold the invariants that fail +# silently, the hooks enforce the never-do rules, AGENTS.md routes to the skills by task, the agents +# encode this build's traps and output contracts, and all five are reviewable in a PR like any other +# doc. Everything else here — `settings.local.json` above all — is per-user state. .claude/* !.claude/rules/ +!.claude/hooks/ !.claude/skills/ !.claude/agents/ !.claude/settings.json