diff --git a/README.md b/README.md index 32ffbf3..f3e7ad3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A deterministic development harness for AI agents. -AI agents write code fast but cut corners — they skip error handling, introduce race conditions, ignore edge cases, and refactor things you didn't ask them to touch. Devkit is the infrastructure layer between you and the agents. A Go engine executes workflows deterministically — branching, loops, budget enforcement, and parallel dispatch all happen in compiled code, not LLM reasoning. 14 hooks enforce quality at every stage of the lifecycle, many learned directly from bugs found in previous review cycles. The system gets better every time it catches something new. +AI agents write code fast but cut corners — they skip error handling, introduce race conditions, ignore edge cases, and refactor things you didn't ask them to touch. Devkit is the infrastructure layer between you and the agents. A Go engine executes workflows deterministically — branching, loops, budget enforcement, and parallel dispatch all happen in compiled code, not LLM reasoning. 9 language-aware hooks enforce quality at every stage of the lifecycle across Go, TypeScript, Rust, Python, and Shell — many learned directly from bugs found in previous review cycles. The system gets better every time it catches something new. Define your workflow in YAML. The engine handles orchestration. The agent handles creativity. Every change is measured, gated, and auditable. @@ -204,7 +204,7 @@ For brainstorming, planning, TDD, verification, and skill authoring — install ## Hooks -Devkit ships 14 hooks across 4 lifecycle events. All are installed automatically with the plugin — no setup required. +Devkit ships 9 hooks across 4 lifecycle events. All are installed automatically with the plugin — no setup required. ### PreToolUse @@ -212,7 +212,6 @@ Devkit ships 14 hooks across 4 lifecycle events. All are installed automatically |---|---|---| | **safety-check** | Bash, Edit, Write | Blocks destructive commands (`rm -rf /`, `DROP TABLE`, private key writes). Prompts on risky operations (force push, `git reset --hard`, editing secrets). | | **security-patterns** | Edit, Write | Catches vulnerability patterns at creation time — `eval()`, XSS, shell injection, weak hashes, hardcoded secrets, path traversal. Language-aware (JS/TS/Python/Go). | -| **shell-compat** | Edit, Write | Flags macOS-incompatible constructs in shell scripts — `grep -P`, `sed -i` without `''`, `readlink -f`, `stat --format`, `xargs -d`, `date -d`. | | **audit-trail** | Bash | Logs every command to `.devkit/audit.log` with UTC timestamps. Auto-rotates at 10k lines. | | **pr-gate** | Bash | Detects `gh pr create` and prompts to run `/devkit:pr-ready` first. 10-minute cooldown. | | **rtk-rewrite** | Bash | Rewrites commands through [RTK](https://github.com/rtk-ai/rtk) for 60-90% token savings. No-op if RTK not installed. | @@ -223,21 +222,19 @@ Devkit ships 14 hooks across 4 lifecycle events. All are installed automatically |---|---|---| | **post-validate** | Bash, Edit, Write | Warns on suppressed errors, leaked secrets in written content, writes outside repo. | | **slop-detect** | Edit, Write | Catches AI code patterns — doc/code ratio imbalance, restating comments, excessive JSDoc in .js files. | -| **go-review** | Edit, Write | Go-specific quality checks — error-path result access, concurrent map access without mutex, unsanitized filepath input. | -| **go-nil-return** | Edit, Write | Detects Go functions with error return type that only ever return nil — catches silent failure patterns. | +| **lang-review** | Edit, Write | Language-aware code quality checks. Detects language from file extension and runs the right checks: Go (error-path access, map races, nil-error returns, filepath traversal), TypeScript (empty catches, any-type, unhandled promises), Rust (unwrap in non-test, let _ = discard, unsafe blocks), Python (bare except, pass-in-except, mutable defaults), Shell (macOS portability — grep -P, sed -i, readlink -f, timeout). | ### SubagentStop | Hook | Matcher | What it does | |---|---|---| -| **subagent-stop** | Stop | Verifies subagent work products before accepting. Recognizes Go, Node, Python, and generic test frameworks. | +| **subagent-stop** | Stop | Verifies subagent work products before accepting. Recognizes Go, Node, Python, Rust, and generic test frameworks. | ### Stop | Hook | Matcher | What it does | |---|---|---| -| **dirty-bit** | Stop | Detects cross-domain changes (backend + frontend + config + SQL) and blocks completion if any touched domain lacks test evidence. | -| **go-vet-stop** | Stop | Runs `go vet` and `go test -race` on modified Go packages before session completes. Catches data races and vet violations. | +| **stop-gate** | Stop | Consolidated quality gate: detects merge conflicts, checks cross-domain test evidence (blocks if backend + frontend changed but only one tested), runs language-appropriate linter (go vet + race detector, cargo clippy, tsc --noEmit, ruff). | --- @@ -347,21 +344,18 @@ devkit/ │ ├── test-writer.md # Sonnet, worktree isolation │ ├── documenter.md # Haiku, worktree isolation │ └── security-auditor.md # Opus, worktree isolation -├── hooks/ # 14 hooks across 4 lifecycle events +├── hooks/ # 9 hooks across 4 lifecycle events │ ├── hooks.json # Hook config (auto-loaded) │ ├── safety-check.sh # Dangerous operation blocker │ ├── security-patterns.sh # Edit-time vulnerability detection -│ ├── shell-compat.sh # macOS portability checker │ ├── audit-trail.sh # Command logging │ ├── rtk-rewrite.sh # Token optimization +│ ├── pr-gate.sh # PR pipeline prompt │ ├── post-validate.sh # Output validation │ ├── slop-detect.sh # AI pattern detection -│ ├── go-review.sh # Go code quality patterns -│ ├── go-nil-return.sh # Go nil-error detection -│ ├── pr-gate.sh # PR pipeline prompt +│ ├── lang-review.sh # Language-aware code quality (Go/TS/Rust/Python/Shell) │ ├── subagent-stop.sh # Subagent work verification -│ ├── dirty-bit.sh # Cross-domain test enforcement -│ └── go-vet-stop.sh # Go vet + race detector +│ └── stop-gate.sh # Consolidated quality gate (cross-domain + vet/lint) ├── workflows/ # 12 YAML workflow definitions ├── presets/ # Reserved for future use ├── .github/workflows/ # CI/CD @@ -535,8 +529,8 @@ See [ROADMAP.md](ROADMAP.md) for full details. - [x] Iteration scratchpads — persistent memory across loop iterations to prevent repeated failures - [x] Cross-domain dirty-bit enforcement — blocks completion without test evidence per domain - [x] Go code quality hooks — error-path access, nil-return, race detection, portability -- [ ] **Language-universal hooks** — consolidate Go-specific hooks (go-review, go-nil-return, go-vet-stop) into a single `lang-review.sh` that detects language from file extension and runs the right checks. Extend to TypeScript (eslint, tsc --noEmit, empty catch blocks), Rust (clippy, unwrap-after-error, `let _ =` discard), and Python (mypy/ruff, bare except, pass-in-catch) -- [ ] **Hook consolidation** — merge per-event hooks into fewer scripts to reduce shell process overhead (currently 7 processes per Edit/Write). Add `if` filters to skip non-matching file types without spawning +- [x] **Language-universal hooks** — consolidated Go-specific hooks into `lang-review.sh` with Go, TypeScript, Rust, Python, and Shell support +- [x] **Hook consolidation** — merged 14 hooks into 9, reduced per-edit shell processes from 7 to 4. Consolidated stop hooks (dirty-bit + go-vet-stop + old stop-gate) into single `stop-gate.sh` - [ ] Stop hook redesign — opt-in or session-end only, not every turn - [ ] Cost event hooks — budget threshold events with auto-downgrade actions - [ ] Execution registry — centralized step tracking with timing and token usage diff --git a/hooks/hooks.json b/hooks/hooks.json index 1bccd63..1fb596b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Multi-layer enforcement stack: PreToolUse safety + security patterns + RTK optimization, PostToolUse validation, SubagentStop verification, Stop quality gate", + "description": "Consolidated enforcement stack: PreToolUse safety + security, PostToolUse validation + language review, SubagentStop verification, Stop quality gate", "hooks": { "PreToolUse": [ { @@ -63,18 +63,7 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/security-patterns.sh", - "statusMessage": "Security pattern check...", - "timeout": 5 - } - ] - }, - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/shell-compat.sh", - "statusMessage": "Shell portability check...", + "statusMessage": "Security check...", "timeout": 5 } ] @@ -119,19 +108,8 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/go-review.sh", - "statusMessage": "Go quality check...", - "timeout": 5 - } - ] - }, - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/go-nil-return.sh", - "statusMessage": "Nil-error check...", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/lang-review.sh", + "statusMessage": "Code quality check...", "timeout": 5 } ] @@ -156,19 +134,8 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dirty-bit.sh", - "statusMessage": "Checking cross-domain coverage...", - "timeout": 10 - } - ] - }, - { - "matcher": "Stop", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/go-vet-stop.sh", - "statusMessage": "Running go vet + race check...", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-gate.sh", + "statusMessage": "Quality gate...", "timeout": 90 } ] diff --git a/hooks/lang-review.sh b/hooks/lang-review.sh new file mode 100755 index 0000000..ad884b2 --- /dev/null +++ b/hooks/lang-review.sh @@ -0,0 +1,253 @@ +#!/bin/bash +# devkit PostToolUse hook — language-aware code quality review +# +# Consolidated hook that replaces go-review.sh and go-nil-return.sh. +# Detects language from file extension and runs the appropriate checks. +# +# Supported languages: +# Go — error-path result access, concurrent map access, nil-error returns, filepath traversal +# TS/JS — empty catch blocks, unhandled promise rejections, any-type usage +# Rust — unwrap-after-error, let _ = discard, unwrap on Option/Result in non-test code +# Python — bare except, pass-in-except, mutable default arguments +# Shell — macOS portability (grep -P, sed -i, readlink -f, stat --format, etc.) +# +# PostToolUse hook schema: +# { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" } } + +set -uo pipefail + +# Safety net: if anything crashes, exit cleanly (hook must never die without output) +trap 'exit 0' ERR + +INPUT=$(cat || true) +[ -z "$INPUT" ] && exit 0 + +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) +CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // empty' 2>/dev/null || true) + +# Only check Edit/Write +if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then + exit 0 +fi +[ -z "$CONTENT" ] && exit 0 + +WARNINGS="" + +add_warning() { + WARNINGS="$WARNINGS\n- $1" +} + +# --------------------------------------------------------------------------- +# Go (.go) +# --------------------------------------------------------------------------- +check_go() { + # Error-path result access — simplified: check if any line between + # "if err != nil {" and its closing "}" references result./res. + if echo "$CONTENT" | grep -qE 'if err != nil'; then + echo "$CONTENT" | awk ' + /if err != nil[[:space:]]*\{/ { in_err = 1; depth = 0 } + in_err { + for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + if (c == "{") depth++ + if (c == "}") depth-- + } + if (/result\.|res\./ && depth > 0) { matched = 1 } + if (depth <= 0) { in_err = 0 } + } + END { exit matched ? 0 : 1 } + ' && AWK_MATCHED=true || AWK_MATCHED=false + if $AWK_MATCHED; then + add_warning "Go: possible result field access inside error path (result may be zero-value when err != nil)" + fi + fi + + # Concurrent map access without protection + if echo "$CONTENT" | grep -qE 'go func' && echo "$CONTENT" | grep -qE 'map\[string\]'; then + if ! echo "$CONTENT" | grep -qE '(sync\.Mutex|sync\.RWMutex|sync\.Map|snapshot|Snap)'; then + add_warning "Go: goroutines with map usage but no visible mutex/snapshot — verify concurrent map access is safe" + fi + fi + + # filepath.Join with unsanitized variable + if echo "$CONTENT" | grep -qE 'filepath\.Join.*\b(name|input|arg|param|user)'; then + if ! echo "$CONTENT" | grep -qE '(regexp|Regexp|MustCompile|MatchString|ValidateName|sanitize)'; then + add_warning "Go: filepath.Join with potentially unsanitized input — validate before constructing paths" + fi + fi + + # Nil-error return detection (functions that always return nil error) + NIL_FUNCS=$(echo "$CONTENT" | awk ' + /^func .*\)[[:space:]]*(\(.*error\)|error)[[:space:]]*\{/ { + fname = $0; sub(/\{.*/, "", fname) + in_func = 1; brace_depth = 0; has_return = 0; has_non_nil_err = 0 + line = $0 + for (j = 1; j <= length(line); j++) { + c = substr(line, j, 1) + if (c == "{") brace_depth++ + if (c == "}") brace_depth-- + } + next + } + in_func { + line = $0 + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (c == "{") brace_depth++ + if (c == "}") brace_depth-- + } + if ($0 ~ /return /) { + has_return = 1 + if ($0 !~ /,[[:space:]]*nil[[:space:]]*$/ && $0 !~ /return nil[[:space:]]*$/ && $0 !~ /,[[:space:]]*nil[[:space:]]*\)/) { + has_non_nil_err = 1 + } + } + if (brace_depth <= 0) { + if (has_return && !has_non_nil_err) { + gsub(/^[[:space:]]+/, "", fname) + print fname + } + in_func = 0 + } + } + ') + if [ -n "$NIL_FUNCS" ]; then + COUNT=$(echo "$NIL_FUNCS" | wc -l | tr -d ' ') + FIRST=$(echo "$NIL_FUNCS" | head -1) + add_warning "Go: ${COUNT} function(s) return error but only ever return nil (e.g., ${FIRST})" + fi +} + +# --------------------------------------------------------------------------- +# TypeScript / JavaScript (.ts, .tsx, .js, .jsx, .mjs, .cjs) +# --------------------------------------------------------------------------- +check_typescript() { + # Empty catch blocks + if echo "$CONTENT" | grep -qE 'catch\s*\([^)]*\)\s*\{\s*\}'; then + add_warning "TS/JS: empty catch block swallows errors silently — log or rethrow" + fi + + # catch with only console.log (no rethrow) + if echo "$CONTENT" | grep -qE 'catch\s*\(' && echo "$CONTENT" | grep -qE 'console\.(log|warn)\('; then + if ! echo "$CONTENT" | grep -qE '(throw|reject|process\.exit)'; then + add_warning "TS/JS: catch block logs but doesn't rethrow — error may be silently swallowed" + fi + fi + + # any type usage + if echo "$CONTENT" | grep -qE ':\s*any\b||as any'; then + add_warning "TS: 'any' type usage — consider a specific type or 'unknown'" + fi + + # Unhandled promise (async function without try/catch or .catch) + if echo "$CONTENT" | grep -qE 'async\s+function|async\s*\('; then + if ! echo "$CONTENT" | grep -qE '(try\s*\{|\.catch\(|await.*\.catch)'; then + add_warning "TS/JS: async function without visible error handling — add try/catch or .catch()" + fi + fi +} + +# --------------------------------------------------------------------------- +# Rust (.rs) +# --------------------------------------------------------------------------- +check_rust() { + # .unwrap() in non-test code + if echo "$CONTENT" | grep -qE '\.unwrap\(\)'; then + if ! echo "$CONTENT" | grep -qE '(#\[test\]|#\[cfg\(test\)\]|mod tests)'; then + add_warning "Rust: .unwrap() in non-test code — use ? operator or handle the error" + fi + fi + + # let _ = discarding Result/Option + if echo "$CONTENT" | grep -qE 'let\s+_\s*=.*\b(Result|Option|Ok|Err)\b|let\s+_\s*=.*\?'; then + add_warning "Rust: discarding Result/Option with let _ = — handle or explicitly document why" + fi + + # expect() with non-descriptive message + if echo "$CONTENT" | grep -qE '\.expect\(\s*"[^"]{0,10}"\s*\)'; then + add_warning "Rust: .expect() with short message — provide a descriptive panic message" + fi + + # unsafe block + if echo "$CONTENT" | grep -qE '\bunsafe\s*\{'; then + add_warning "Rust: unsafe block — verify memory safety invariants are maintained" + fi +} + +# --------------------------------------------------------------------------- +# Python (.py) +# --------------------------------------------------------------------------- +check_python() { + # Bare except + if echo "$CONTENT" | grep -qE '^\s*except\s*:'; then + add_warning "Python: bare 'except:' catches everything including KeyboardInterrupt — use 'except Exception:'" + fi + + # except with pass (silent swallow) + if echo "$CONTENT" | grep -qE 'except.*:\s*$' && echo "$CONTENT" | grep -qE '^\s*pass\s*$'; then + add_warning "Python: 'except: pass' silently swallows errors — log or handle" + fi + + # Mutable default arguments + if echo "$CONTENT" | grep -qE 'def\s+\w+\(.*=\s*(\[\]|\{\}|set\(\))'; then + add_warning "Python: mutable default argument (list/dict/set) — use None and initialize inside function" + fi + + # Generic Exception catch + if echo "$CONTENT" | grep -qE 'except\s+Exception\s+as\s+\w+\s*:\s*$' && echo "$CONTENT" | grep -qE '^\s*pass\s*$'; then + add_warning "Python: catching Exception and passing — error is silently lost" + fi +} + +# --------------------------------------------------------------------------- +# Shell (.sh) +# --------------------------------------------------------------------------- +check_shell() { + # macOS portability checks + echo "$CONTENT" | grep -qE 'grep\s+(-[a-zA-Z]*P|--perl-regexp)' && \ + add_warning "Shell: grep -P (Perl regex) unavailable on macOS — use grep -E, awk, or perl" + + echo "$CONTENT" | grep -qE 'sed\s+-i\s+[^'"'"'"]' && \ + add_warning "Shell: sed -i without '' breaks on macOS BSD sed — use sed -i ''" + + echo "$CONTENT" | grep -qE 'readlink\s+-f\b' && \ + add_warning "Shell: readlink -f is GNU-only — use realpath or manual loop on macOS" + + echo "$CONTENT" | grep -qE 'stat\s+--format' && \ + add_warning "Shell: stat --format is GNU-only — use stat -f on macOS" + + echo "$CONTENT" | grep -qE 'xargs\s+-d\b' && \ + add_warning "Shell: xargs -d is GNU-only — use tr + xargs on macOS" + + echo "$CONTENT" | grep -qE 'date\s+-d\b' && \ + add_warning "Shell: date -d is GNU-only — use date -j -f on macOS" + + echo "$CONTENT" | grep -qE '\btimeout\s+[0-9]' && \ + add_warning "Shell: timeout command is GNU-only — use perl -e 'alarm N; exec @ARGV' on macOS" +} + +# --------------------------------------------------------------------------- +# Dispatch by file extension +# --------------------------------------------------------------------------- +case "$FILE_PATH" in + *.go) check_go ;; + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) check_typescript ;; + *.rs) check_rust ;; + *.py) check_python ;; + *.sh) check_shell ;; + *) exit 0 ;; +esac + +if [ -n "$WARNINGS" ]; then + MSG=$(printf "Code quality check:%b" "$WARNINGS") + jq -n --arg msg "$MSG" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: $msg + } + }' + exit 0 +fi + +exit 0 diff --git a/hooks/stop-gate.sh b/hooks/stop-gate.sh index 9600716..0f71e5e 100755 --- a/hooks/stop-gate.sh +++ b/hooks/stop-gate.sh @@ -1,71 +1,182 @@ #!/bin/bash -# devkit Stop hook — final quality gate before session ends +# devkit Stop hook — consolidated quality gate # -# Checks for common issues that indicate incomplete work: -# - Uncommitted changes left in the working tree -# - Merge conflict markers in tracked files -# - TODO/FIXME markers introduced in the current diff +# Replaces dirty-bit.sh + go-vet-stop.sh + old stop-gate.sh with a single hook. # -# Warns once, then stays quiet for 5 minutes to avoid spamming. +# Phase 1: Basic checks (uncommitted changes, conflict markers, TODOs) +# Phase 2: Cross-domain test evidence (dirty-bit logic) +# Phase 3: Language-specific linter/vet (go vet, clippy, tsc, ruff) # # Stop hook schema: # { "decision": "approve" | "block", "reason": "string" } -COOLDOWN_FILE="/tmp/devkit-stop-gate-cooldown" -COOLDOWN_SECONDS=300 - -# Check cooldown first — if we warned recently, approve silently -if [ -f "$COOLDOWN_FILE" ] 2>/dev/null; then - LAST=$(cat "$COOLDOWN_FILE" 2>/dev/null) - NOW=$(date +%s 2>/dev/null) - if [ -n "$LAST" ] && [ -n "$NOW" ]; then - ELAPSED=$(( NOW - LAST )) 2>/dev/null || ELAPSED=0 - if [ "$ELAPSED" -lt "$COOLDOWN_SECONDS" ] 2>/dev/null; then - jq -n '{ decision: "approve" }' - exit 0 - fi - fi +set -uo pipefail + +# Safety net: Stop hooks MUST return JSON. If anything crashes, approve rather than hang. +trap 'jq -n "{ decision: \"approve\" }" 2>/dev/null || echo "{\"decision\":\"approve\"}"; exit 0' ERR + +INPUT=$(cat || true) +[ -z "$INPUT" ] && { jq -n '{ decision: "approve" }'; exit 0; } + +TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript // empty' 2>/dev/null || true) + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +CHANGED_FILES=$(cd "$REPO_ROOT" && { + git diff --name-only HEAD 2>/dev/null + git diff --name-only --cached 2>/dev/null + git diff --name-only 2>/dev/null +} | sort -u) + +if [ -z "$CHANGED_FILES" ]; then + jq -n '{ decision: "approve" }' + exit 0 fi -WARNINGS="" +# --------------------------------------------------------------------------- +# Phase 1: Basic quality checks +# --------------------------------------------------------------------------- + +# Merge conflict markers +CONFLICT_PATTERN='<''<<''<<''<< ' +CONFLICTS=$(echo "$CHANGED_FILES" | while IFS= read -r f; do + [ -f "$REPO_ROOT/$f" ] && grep -l -- "$CONFLICT_PATTERN" "$REPO_ROOT/$f" 2>/dev/null || true +done | head -3) +if [ -n "$CONFLICTS" ]; then + jq -n --arg files "$CONFLICTS" '{ + decision: "block", + reason: ("Merge conflict markers found in: " + $files) + }' + exit 0 +fi + +# --------------------------------------------------------------------------- +# Phase 2: Classify domains and check cross-domain test evidence +# --------------------------------------------------------------------------- +HAS_GO=false +HAS_TS=false +HAS_RUST=false +HAS_PYTHON=false +HAS_CONFIG=false +HAS_SQL=false + +while IFS= read -r file; do + case "$file" in + *.go) HAS_GO=true ;; + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) HAS_TS=true ;; + *.rs) HAS_RUST=true ;; + *.py) HAS_PYTHON=true ;; + *.yml|*.yaml|*.json|*.toml|*.ini|*.env*) HAS_CONFIG=true ;; + *.sql|*/migrations/*|*/migrate/*) HAS_SQL=true ;; + esac +done <<< "$CHANGED_FILES" + +# Count code domains (exclude config — doesn't need its own test evidence) +CODE_DOMAINS=0 +DOMAINS="" +$HAS_GO && CODE_DOMAINS=$((CODE_DOMAINS + 1)) && DOMAINS="$DOMAINS go" +$HAS_TS && CODE_DOMAINS=$((CODE_DOMAINS + 1)) && DOMAINS="$DOMAINS typescript" +$HAS_RUST && CODE_DOMAINS=$((CODE_DOMAINS + 1)) && DOMAINS="$DOMAINS rust" +$HAS_PYTHON && CODE_DOMAINS=$((CODE_DOMAINS + 1)) && DOMAINS="$DOMAINS python" +$HAS_SQL && CODE_DOMAINS=$((CODE_DOMAINS + 1)) && DOMAINS="$DOMAINS sql" +DOMAINS=$(echo "$DOMAINS" | xargs) + +# Skip cross-domain check if transcript is empty (no data to verify against) +if [ "$CODE_DOMAINS" -gt 1 ] && [ -n "$TRANSCRIPT" ]; then + MISSING="" + + $HAS_GO && ! echo "$TRANSCRIPT" | grep -qiE '(go test|ALL_PASSING|ALL_TESTS_PASSING)' && MISSING="$MISSING go" + $HAS_TS && ! echo "$TRANSCRIPT" | grep -qiE '(npm test|npx jest|npx vitest|yarn test|pnpm test|ALL_PASSING)' && MISSING="$MISSING typescript" + $HAS_RUST && ! echo "$TRANSCRIPT" | grep -qiE '(cargo test|ALL_PASSING)' && MISSING="$MISSING rust" + $HAS_PYTHON && ! echo "$TRANSCRIPT" | grep -qiE '(pytest|python.*-m.*test|ALL_PASSING)' && MISSING="$MISSING python" + $HAS_SQL && ! echo "$TRANSCRIPT" | grep -qiE '(migrate|migration.*up|ALL_PASSING)' && MISSING="$MISSING sql" -# Check for uncommitted changes -if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - DIRTY=$(git status --porcelain 2>/dev/null | head -5) - if [ -n "$DIRTY" ]; then - WARNINGS="${WARNINGS}Uncommitted changes detected. " + MISSING=$(echo "$MISSING" | xargs) + if [ -n "$MISSING" ]; then + jq -n --arg domains "$DOMAINS" --arg missing "$MISSING" '{ + decision: "block", + reason: ("Cross-domain changes (touched: " + $domains + "). Missing test evidence for: " + $missing) + }' + exit 0 fi +fi + +# --------------------------------------------------------------------------- +# Phase 3: Language-specific vet/lint +# --------------------------------------------------------------------------- + +# --- Go --- +if $HAS_GO && command -v go >/dev/null 2>&1; then + GO_MOD_DIR="" + for candidate in "$REPO_ROOT" "$REPO_ROOT/src" "$REPO_ROOT/cmd"; do + [ -f "$candidate/go.mod" ] && GO_MOD_DIR="$candidate" && break + done - # Check for merge conflict markers in staged/modified files - CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null || true) - if [ -n "$CHANGED_FILES" ]; then - # Split the pattern so this file doesn't match itself - CONFLICT_PATTERN='<''<<''<<''<< ' - CONFLICTS=$(git diff --name-only -z HEAD 2>/dev/null | xargs -0 grep -l -- "$CONFLICT_PATTERN" 2>/dev/null | head -3 || true) - if [ -n "$CONFLICTS" ]; then - WARNINGS="${WARNINGS}Merge conflict markers found in: ${CONFLICTS}. " + if [ -n "$GO_MOD_DIR" ]; then + VET_OUTPUT=$(cd "$GO_MOD_DIR" && go vet ./... 2>&1) || true + if [ -n "$VET_OUTPUT" ]; then + jq -n --arg msg "go vet found issues:\n$VET_OUTPUT" '{ decision: "block", reason: $msg }' + exit 0 fi - # Check for new TODO/FIXME in diff (not in the whole file, just new lines) - NEW_TODOS=$(git diff HEAD 2>/dev/null | grep '^+' | grep -iE '(TODO|FIXME|HACK|XXX):' | head -3 || true) - if [ -n "$NEW_TODOS" ]; then - WARNINGS="${WARNINGS}New TODO/FIXME markers in diff. " + # Race detection on changed packages — paths must be relative to GO_MOD_DIR with ./ prefix + MOD_REL=$(echo "$GO_MOD_DIR" | sed "s|^$REPO_ROOT/||; s|^$REPO_ROOT$||") + GO_PKGS=$(echo "$CHANGED_FILES" | grep '\.go$' | while IFS= read -r f; do + # Strip the module-relative prefix and add ./ + pkg=$(dirname "$f") + if [ -n "$MOD_REL" ]; then + pkg=$(echo "$pkg" | sed "s|^$MOD_REL/||") + fi + echo "./$pkg" + done | sort -u | tr '\n' ' ') + + if [ -n "$GO_PKGS" ]; then + RACE_OUTPUT=$(cd "$GO_MOD_DIR" && perl -e 'alarm 60; exec @ARGV' -- go test -race -count=1 $GO_PKGS 2>&1) || RACE_EXIT=$? + if [ "${RACE_EXIT:-0}" -ne 0 ]; then + if echo "$RACE_OUTPUT" | grep -qE 'DATA RACE|race detected'; then + RACE_LINES=$(echo "$RACE_OUTPUT" | grep -A5 'DATA RACE' | head -20) + jq -n --arg msg "Race condition detected:\n$RACE_LINES" '{ decision: "block", reason: $msg }' + exit 0 + fi + # Non-race test failure — warn but don't block (tests are checked elsewhere) + fi fi fi fi -# If warnings found, block once and start cooldown -if [ -n "$WARNINGS" ]; then - date +%s > "$COOLDOWN_FILE" 2>/dev/null - jq -n --arg reason "Quality gate: ${WARNINGS}Continue anyway?" '{ - decision: "block", - reason: $reason - }' - exit 0 +# --- Rust --- +if $HAS_RUST && command -v cargo >/dev/null 2>&1 && [ -f "$REPO_ROOT/Cargo.toml" ]; then + CLIPPY_OUTPUT=$(cd "$REPO_ROOT" && cargo clippy --quiet 2>&1) || true + if echo "$CLIPPY_OUTPUT" | grep -qE 'error\['; then + CLIPPY_ERRORS=$(echo "$CLIPPY_OUTPUT" | grep -E 'error\[' | head -5) + jq -n --arg msg "cargo clippy errors:\n$CLIPPY_ERRORS" '{ decision: "block", reason: $msg }' + exit 0 + fi +fi + +# --- TypeScript --- +if $HAS_TS && [ -f "$REPO_ROOT/tsconfig.json" ] && command -v npx >/dev/null 2>&1; then + TSC_OUTPUT=$(cd "$REPO_ROOT" && npx tsc --noEmit 2>&1) || TSC_EXIT=$? + if [ "${TSC_EXIT:-0}" -ne 0 ]; then + TSC_ERRORS=$(echo "$TSC_OUTPUT" | grep -E 'error TS' | head -5 || true) + if [ -n "$TSC_ERRORS" ]; then + jq -n --arg msg "TypeScript errors:\n$TSC_ERRORS" '{ decision: "block", reason: $msg }' + exit 0 + fi + fi +fi + +# --- Python --- +if $HAS_PYTHON && command -v ruff >/dev/null 2>&1; then + PY_FILES=$(echo "$CHANGED_FILES" | grep '\.py$' || true) + if [ -n "$PY_FILES" ]; then + RUFF_OUTPUT=$(cd "$REPO_ROOT" && echo "$PY_FILES" | xargs ruff check 2>&1) || true + if echo "$RUFF_OUTPUT" | grep -qE '^[^ ]+\.py:[0-9]+'; then + RUFF_ERRORS=$(echo "$RUFF_OUTPUT" | head -5) + jq -n --arg msg "ruff found issues:\n$RUFF_ERRORS" '{ decision: "block", reason: $msg }' + exit 0 + fi + fi fi -# All clear -jq -n '{ - decision: "approve" -}' +jq -n '{ decision: "approve" }' exit 0