diff --git a/.claude/hooks/commitlint-check.js b/.claude/hooks/commitlint-check.js new file mode 100644 index 00000000..d93e56b9 --- /dev/null +++ b/.claude/hooks/commitlint-check.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// commitlint-check.js — Local commitlint validation +// Mirrors @commitlint/config-conventional + project commitlint.config.ts. +// Called by commitlint-local.sh with the commit message as argv[1]. + +const MAX = 100; +const TYPES = [ + "feat", "fix", "docs", "refactor", "test", "chore", + "ci", "perf", "build", "style", "revert", "release", "merge", +]; + +const msg = process.argv[2]; +if (!msg) process.exit(0); + +// Skip merge commits (matches commitlint ignores config) +if (/^merge[:\s]/i.test(msg)) process.exit(0); + +const lines = msg.split("\n"); +const header = lines[0] || ""; +const errors = []; + +// --- Header checks --- + +// type-empty + subject-empty: header must match type(scope)?: subject +const headerMatch = header.match(/^(\w+)(\(.+\))?(!)?:\s*(.*)$/); +if (!headerMatch) { + errors.push("header must match format: type(scope)?: subject"); +} else { + const [, type, , , subject] = headerMatch; + + // type-case: must be lowercase + if (type !== type.toLowerCase()) { + errors.push(`type must be lowercase: "${type}"`); + } + + // type-enum: must be in allowed list + if (!TYPES.includes(type.toLowerCase())) { + errors.push(`type "${type}" not in allowed types: ${TYPES.join(", ")}`); + } + + // subject-empty + if (!subject || !subject.trim()) { + errors.push("subject must not be empty"); + } + + // subject-full-stop + if (subject && subject.trimEnd().endsWith(".")) { + errors.push("subject must not end with a period"); + } +} + +// header-max-length +if (header.length > MAX) { + errors.push( + `header is ${header.length} chars (max ${MAX}): ${header.substring(0, 60)}...` + ); +} + +// --- Body/footer line length checks --- +for (let i = 2; i < lines.length; i++) { + if (lines[i].length > MAX) { + errors.push( + `line ${i + 1} is ${lines[i].length} chars (max ${MAX}): ${lines[i].substring(0, 60)}...` + ); + } +} + +if (errors.length > 0) { + // Output one error per line to stdout + process.stdout.write(errors.join("\n")); + process.exit(1); +} diff --git a/.claude/hooks/commitlint-local.sh b/.claude/hooks/commitlint-local.sh new file mode 100644 index 00000000..7d1fc03e --- /dev/null +++ b/.claude/hooks/commitlint-local.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# commitlint-local.sh — PreToolUse hook for Bash (git commit) +# Validates commit message format locally before the commit runs, +# catching violations that would fail CI commitlint. +# Delegates validation to commitlint-check.js. + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Skip --amend without -m (reuses existing message) +if echo "$COMMAND" | grep -qE '\-\-amend' && ! echo "$COMMAND" | grep -qE '\s-m\s'; then + exit 0 +fi + +# Skip heredoc-style messages (shell code only; can't validate pre-execution) +if echo "$COMMAND" | grep -qE '\$\(cat <<'; then + exit 0 +fi + +# Extract the commit message from -m flag using node for robust parsing +MSG=$(echo "$COMMAND" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const cmd = d; + let msg = ''; + // Match -m \"...\" or -m '...' + const dq = cmd.match(/-m\s+\"([\\s\\S]*?)\"(?:\s|$)/); + if (dq) { msg = dq[1]; } + else { + const sq = cmd.match(/-m\s+'([^']*)'/); + if (sq) { msg = sq[1]; } + } + // Unescape \\n to real newlines + msg = msg.replace(/\\\\n/g, '\\n'); + process.stdout.write(msg); + }); +" 2>/dev/null) || true + +if [ -z "$MSG" ]; then + exit 0 +fi + +# Run commitlint checks +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" +VIOLATIONS=$(node "$HOOK_DIR/commitlint-check.js" "$MSG" 2>/dev/null) || true + +if [ -n "$VIOLATIONS" ]; then + REASON="Commit message fails commitlint rules:"$'\n'"${VIOLATIONS}"$'\n'"Fix the message to match conventional commit format." + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: process.argv[1] + } + })); + " "$REASON" +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 2f9b092f..9d39db73 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,6 +19,11 @@ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-pr-body.sh\"", "timeout": 10 }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/commitlint-local.sh\"", + "timeout": 10 + }, { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit.sh\"",