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
72 changes: 72 additions & 0 deletions .claude/hooks/commitlint-check.js
Original file line number Diff line number Diff line change
@@ -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);
}
81 changes: 81 additions & 0 deletions .claude/hooks/commitlint-local.sh
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
Loading