Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions hooks/dirty-bit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/bin/bash
# devkit Stop hook — dirty-bit feedback loop
#
# Tracks which domains were modified and warns if the appropriate
# verification (tests/lint) hasn't been run for each touched domain.
#
# Domains: backend (Go/Python), frontend (TS/JS/JSX/TSX), config (YAML/JSON/TOML),
# test files, SQL/migrations
#
# Stop hook schema:
# { "decision": "approve" | "block", "reason": "string" }

set -euo pipefail

INPUT=$(cat)
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript // empty')

# Get modified files from git
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)

if [ -z "$CHANGED_FILES" ]; then
jq -n '{ decision: "approve" }'
exit 0
fi

# Classify changed files into domains
BACKEND=false
FRONTEND=false
CONFIG=false
SQL=false
DOMAINS_TOUCHED=""

while IFS= read -r file; do
case "$file" in
*.go|*.py|*.rb|*.java|*.rs)
BACKEND=true ;;
*.ts|*.tsx|*.js|*.jsx|*.vue|*.svelte)
FRONTEND=true ;;
*.yml|*.yaml|*.json|*.toml|*.ini|*.env*)
CONFIG=true ;;
*.sql|**/migrations/*|**/migrate/*)
SQL=true ;;
esac
done <<< "$CHANGED_FILES"

# Build list of touched domains
if [ "$BACKEND" = "true" ]; then DOMAINS_TOUCHED="$DOMAINS_TOUCHED backend"; fi
if [ "$FRONTEND" = "true" ]; then DOMAINS_TOUCHED="$DOMAINS_TOUCHED frontend"; fi
if [ "$CONFIG" = "true" ]; then DOMAINS_TOUCHED="$DOMAINS_TOUCHED config"; fi
if [ "$SQL" = "true" ]; then DOMAINS_TOUCHED="$DOMAINS_TOUCHED sql"; fi

# If only one domain or no code domains, approve
DOMAIN_COUNT=$(echo "$DOMAINS_TOUCHED" | wc -w | tr -d ' ')
if [ "$DOMAIN_COUNT" -le 1 ]; then
jq -n '{ decision: "approve" }'
exit 0
fi

# Multiple domains touched — check for test evidence per domain
MISSING_VERIFICATION=""

if [ "$BACKEND" = "true" ]; then
if ! echo "$TRANSCRIPT" | grep -qiE '(go test|pytest|python.*test|cargo test|bundle exec.*test|ALL_PASSING|ALL_TESTS_PASSING)'; then
MISSING_VERIFICATION="$MISSING_VERIFICATION backend"
fi
fi

if [ "$FRONTEND" = "true" ]; then
if ! echo "$TRANSCRIPT" | grep -qiE '(npm test|npx jest|npx vitest|yarn test|pnpm test|ALL_PASSING|ALL_TESTS_PASSING)'; then
MISSING_VERIFICATION="$MISSING_VERIFICATION frontend"
fi
fi

if [ "$SQL" = "true" ]; then
if ! echo "$TRANSCRIPT" | grep -qiE '(migrate|migration.*up|schema.*applied|ALL_PASSING)'; then
MISSING_VERIFICATION="$MISSING_VERIFICATION sql/migrations"
fi
fi

# If everything verified, approve
if [ -z "$MISSING_VERIFICATION" ]; then
jq -n '{ decision: "approve" }'
exit 0
fi

# Multiple domains touched, some unverified — block
DOMAINS_MSG=$(echo "$DOMAINS_TOUCHED" | xargs)
MISSING_MSG=$(echo "$MISSING_VERIFICATION" | xargs)

jq -n --arg domains "$DOMAINS_MSG" --arg missing "$MISSING_MSG" '{
decision: "block",
reason: ("Cross-domain changes detected (touched: " + $domains + "). Missing test/verification evidence for: " + $missing + ". Run the relevant test suite for each domain before completing.")
}'
exit 0
101 changes: 101 additions & 0 deletions hooks/go-nil-return.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/bin/bash
# devkit PostToolUse hook — detects Go functions that always return nil error
#
# Scans written Go code for functions with error return types where
# every return statement returns nil for the error. This pattern
# silently swallows failures and is a top LLM-generated bug category.
#
# PostToolUse hook schema:
# { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" } }

set -euo pipefail

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // empty')

# Only check Go files on Edit/Write
case "$FILE_PATH" in
*.go) ;;
*) exit 0 ;;
esac
if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then
exit 0
fi
[ -z "$CONTENT" ] && exit 0

# Use awk to find functions that return error but only ever return nil
# Strategy: track function signatures with error returns, then check
# if ALL return statements in that function use nil for the error position
WARNINGS=$(echo "$CONTENT" | awk '
# Match function declarations that return error (last return type)
/^func .*\)\s*(\(.*error\)|error)\s*\{/ {
fname = $0
sub(/\{.*/, "", fname)
in_func = 1
brace_depth = 0
has_return = 0
has_non_nil_err = 0
# Count all braces on the declaration line itself
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 {
# Track brace depth
line = $0
for (i = 1; i <= length(line); i++) {
c = substr(line, i, 1)
if (c == "{") brace_depth++
if (c == "}") brace_depth--
}

# Check return statements
if ($0 ~ /return /) {
has_return = 1
# Check if error position is non-nil (not "nil" or "nil)")
if ($0 !~ /,\s*nil\s*$/ && $0 !~ /return nil\s*$/ && $0 !~ /,\s*nil\s*\)/) {
has_non_nil_err = 1
}
}

# End of function
if (brace_depth <= 0) {
if (has_return && !has_non_nil_err) {
# Strip leading whitespace from function name
gsub(/^[[:space:]]+/, "", fname)
print fname
}
in_func = 0
}
}
')

if [ -n "$WARNINGS" ]; then
# Limit to first 3 functions to avoid noise
FUNCS=$(echo "$WARNINGS" | head -3)
COUNT=$(echo "$WARNINGS" | wc -l | tr -d ' ')
MSG="Go nil-error pattern: ${COUNT} function(s) return error but only ever return nil. This silently swallows failures:"
while IFS= read -r fn; do
MSG="$MSG\n - $fn"
done <<< "$FUNCS"
if [ "$COUNT" -gt 3 ]; then
MSG="$MSG\n ... and $((COUNT - 3)) more"
fi

jq -n --arg msg "$MSG" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $msg
}
}'
exit 0
fi

exit 0
72 changes: 72 additions & 0 deletions hooks/go-review.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/bin/bash
# devkit PostToolUse hook — Go code quality patterns
#
# Checks written Go code for common LLM-generated bug patterns:
# 1. Accessing result fields in error paths
# 2. Goroutines reading shared maps without protection
# 3. Functions that always return nil error
# 4. Unsanitized user input in filepath operations
#
# PostToolUse hook schema:
# { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" } }

set -euo pipefail

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // empty')

# Only check Go files
case "$FILE_PATH" in
*.go) ;;
*) exit 0 ;;
esac

# Only check Edit/Write
if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then
exit 0
fi

if [ -z "$CONTENT" ]; then
exit 0
fi

WARNINGS=""

# Pattern 1: Accessing result after error check
# Detects: if err != nil { ... result. or res. ... } on nearby lines
# Uses awk instead of grep -P to stay macOS-compatible
if echo "$CONTENT" | grep -qE 'if err != nil'; then
if echo "$CONTENT" | awk '/if err != nil \{/{found=1; buf=""} found{buf=buf $0 "\n"; if(/\}/){if(buf ~ /result\.|res\./){exit 0} found=0}} END{exit 1}'; then
WARNINGS="$WARNINGS\n- Possible result field access inside error path (result may be zero-value when err != nil)"
fi
fi

# Pattern 2: Goroutines with shared map access
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
WARNINGS="$WARNINGS\n- Goroutines detected with map usage but no visible mutex/snapshot — verify concurrent map access is safe"
fi
fi

# Pattern 3: 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
WARNINGS="$WARNINGS\n- filepath.Join with potentially unsanitized input — validate before constructing paths"
fi
fi

if [ -n "$WARNINGS" ]; then
MSG=$(printf "Go code quality check:%b" "$WARNINGS")
jq -n --arg msg "$MSG" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $msg
}
}'
exit 0
fi

# All clear
exit 0
82 changes: 82 additions & 0 deletions hooks/go-vet-stop.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/bin/bash
# devkit Stop hook — enforces go vet + race detector on Go changes
#
# When Go files were modified in the session, runs go vet and
# go test -race to catch concurrency bugs before session completes.
#
# Stop hook schema:
# { "decision": "approve" | "block", "reason": "string" }

set -euo pipefail

# Check if any Go files were modified
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
GO_CHANGES=$(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
} | grep '\.go$' | sort -u)

if [ -z "$GO_CHANGES" ]; then
jq -n '{ decision: "approve" }'
exit 0
fi

# Find the Go module root (directory containing go.mod)
GO_MOD_DIR=""
for candidate in "$REPO_ROOT" "$REPO_ROOT/src" "$REPO_ROOT/cmd"; do
if [ -f "$candidate/go.mod" ]; then
GO_MOD_DIR="$candidate"
break
fi
done

if [ -z "$GO_MOD_DIR" ]; then
# No go.mod found — can't run vet, approve and move on
jq -n '{ decision: "approve" }'
exit 0
fi

# Run go vet
VET_OUTPUT=$(cd "$GO_MOD_DIR" && go vet ./... 2>&1) || true
if [ -n "$VET_OUTPUT" ]; then
jq -n --arg msg "go vet found issues in modified Go files. Fix before completing:\n$VET_OUTPUT" '{
decision: "block",
reason: $msg
}'
exit 0
fi

# Run go test -race on packages with changes (limited to 60s)
# Extract unique package directories from changed files
PACKAGES=""
while IFS= read -r file; do
dir=$(dirname "$file")
# Convert filesystem path to Go package path relative to module
rel=$(echo "$dir" | sed "s|^${GO_MOD_DIR#$REPO_ROOT/}/||; s|^${GO_MOD_DIR#$REPO_ROOT/}$|.|")
if [ "$rel" = "$dir" ]; then
rel="./$(echo "$dir" | sed "s|^src/||")"
fi
PACKAGES="$PACKAGES ./$rel"
done <<< "$GO_CHANGES"
PACKAGES=$(echo "$PACKAGES" | tr ' ' '\n' | sort -u | tr '\n' ' ')

if [ -n "$PACKAGES" ]; then
# Use perl alarm for POSIX-compatible timeout (macOS has no `timeout` command)
RACE_OUTPUT=$(cd "$GO_MOD_DIR" && perl -e 'alarm 60; exec @ARGV' -- go test -race -count=1 $PACKAGES 2>&1) || RACE_EXIT=$?
if [ "${RACE_EXIT:-0}" -ne 0 ]; then
# Check if it's specifically a race condition
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 in modified Go packages:\n$RACE_LINES" '{
decision: "block",
reason: $msg
}'
exit 0
fi
# Test failure but not a race — don't block on this hook (dirty-bit handles test coverage)
fi
fi

jq -n '{ decision: "approve" }'
exit 0
Loading
Loading