mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-31 06:03:29 +08:00
406 lines
12 KiB
JavaScript
406 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* PreToolUse Hook: Pre-commit Quality Check
|
|
*
|
|
* Runs quality checks before git commit commands:
|
|
* - Detects staged files
|
|
* - Runs linter on staged files (if available)
|
|
* - Checks for common issues (console.log, TODO, etc.)
|
|
* - Validates commit message format (if provided)
|
|
*
|
|
* Cross-platform (Windows, macOS, Linux)
|
|
*
|
|
* Exit codes:
|
|
* 0 - Success (allow commit)
|
|
* 2 - Block commit (quality issues found)
|
|
*/
|
|
|
|
const { spawnSync } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const MAX_STDIN = 1024 * 1024; // 1MB limit
|
|
|
|
/**
|
|
* Detect staged files for commit
|
|
* @returns {string[]} Array of staged file paths
|
|
*/
|
|
function getStagedFiles() {
|
|
const result = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
if (result.status !== 0) {
|
|
return [];
|
|
}
|
|
return result.stdout.trim().split('\n').filter(f => f.length > 0);
|
|
}
|
|
|
|
function getStagedFileContent(filePath) {
|
|
const result = spawnSync('git', ['show', `:${filePath}`], {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
if (result.status !== 0) {
|
|
return null;
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
/**
|
|
* Check if a file should be quality-checked
|
|
* @param {string} filePath
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldCheckFile(filePath) {
|
|
const checkableExtensions = ['.js', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs'];
|
|
return checkableExtensions.some(ext => filePath.endsWith(ext));
|
|
}
|
|
|
|
/**
|
|
* Find issues in file content
|
|
* @param {string} filePath
|
|
* @returns {object[]} Array of issues found
|
|
*/
|
|
function findFileIssues(filePath) {
|
|
const issues = [];
|
|
|
|
try {
|
|
const content = getStagedFileContent(filePath);
|
|
if (content == null) {
|
|
return issues;
|
|
}
|
|
const lines = content.split('\n');
|
|
|
|
lines.forEach((line, index) => {
|
|
const lineNum = index + 1;
|
|
|
|
// Check for console.log
|
|
if (line.includes('console.log') && !line.trim().startsWith('//') && !line.trim().startsWith('*')) {
|
|
issues.push({
|
|
type: 'console.log',
|
|
message: `console.log found at line ${lineNum}`,
|
|
line: lineNum,
|
|
severity: 'warning'
|
|
});
|
|
}
|
|
|
|
// Check for debugger statements
|
|
if (/\bdebugger\b/.test(line) && !line.trim().startsWith('//')) {
|
|
issues.push({
|
|
type: 'debugger',
|
|
message: `debugger statement at line ${lineNum}`,
|
|
line: lineNum,
|
|
severity: 'error'
|
|
});
|
|
}
|
|
|
|
// Check for TODO/FIXME without issue reference
|
|
const todoMatch = line.match(/\/\/\s*(TODO|FIXME):?\s*(.+)/);
|
|
if (todoMatch && !todoMatch[2].match(/#\d+|issue/i)) {
|
|
issues.push({
|
|
type: 'todo',
|
|
message: `TODO/FIXME without issue reference at line ${lineNum}: "${todoMatch[2].trim()}"`,
|
|
line: lineNum,
|
|
severity: 'info'
|
|
});
|
|
}
|
|
|
|
// Check for hardcoded secrets (basic patterns)
|
|
const secretPatterns = [
|
|
{ pattern: /sk-[a-zA-Z0-9]{20,}/, name: 'OpenAI API key' },
|
|
{ pattern: /ghp_[a-zA-Z0-9]{36}/, name: 'GitHub PAT' },
|
|
{ pattern: /AKIA[A-Z0-9]{16}/, name: 'AWS Access Key' },
|
|
{ pattern: /api[_-]?key\s*[=:]\s*['"][^'"]+['"]/i, name: 'API key' }
|
|
];
|
|
|
|
for (const { pattern, name } of secretPatterns) {
|
|
if (pattern.test(line)) {
|
|
issues.push({
|
|
type: 'secret',
|
|
message: `Potential ${name} exposed at line ${lineNum}`,
|
|
line: lineNum,
|
|
severity: 'error'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} catch {
|
|
// File not readable, skip
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
/**
|
|
* Validate commit message format
|
|
* @param {string} command
|
|
* @returns {object|null} Validation result or null if no message to validate
|
|
*/
|
|
function validateCommitMessage(command) {
|
|
// Extract commit message from command
|
|
const messageMatch = command.match(/(?:-m|--message)[=\s]+["']?([^"']+)["']?/);
|
|
if (!messageMatch) return null;
|
|
|
|
const message = messageMatch[1];
|
|
const issues = [];
|
|
|
|
// Check conventional commit format
|
|
const conventionalCommit = /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?:\s*.+/;
|
|
if (!conventionalCommit.test(message)) {
|
|
issues.push({
|
|
type: 'format',
|
|
message: 'Commit message does not follow conventional commit format',
|
|
suggestion: 'Use format: type(scope): description (e.g., "feat(auth): add login flow")'
|
|
});
|
|
}
|
|
|
|
// Check message length
|
|
if (message.length > 72) {
|
|
issues.push({
|
|
type: 'length',
|
|
message: `Commit message too long (${message.length} chars, max 72)`,
|
|
suggestion: 'Keep the first line under 72 characters'
|
|
});
|
|
}
|
|
|
|
// Check for lowercase first letter (conventional)
|
|
if (conventionalCommit.test(message)) {
|
|
const afterColon = message.split(':')[1];
|
|
if (afterColon && /^[A-Z]/.test(afterColon.trim())) {
|
|
issues.push({
|
|
type: 'capitalization',
|
|
message: 'Subject should start with lowercase after type',
|
|
suggestion: 'Use lowercase for the first letter of the subject'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for trailing period
|
|
if (message.endsWith('.')) {
|
|
issues.push({
|
|
type: 'punctuation',
|
|
message: 'Commit message should not end with a period',
|
|
suggestion: 'Remove the trailing period'
|
|
});
|
|
}
|
|
|
|
return { message, issues };
|
|
}
|
|
|
|
/**
|
|
* Run linter on staged files
|
|
* @param {string[]} files
|
|
* @returns {object} Lint results
|
|
*/
|
|
function runLinter(files) {
|
|
const jsFiles = files.filter(f => /\.(js|jsx|ts|tsx)$/.test(f));
|
|
const pyFiles = files.filter(f => f.endsWith('.py'));
|
|
const goFiles = files.filter(f => f.endsWith('.go'));
|
|
|
|
const results = {
|
|
eslint: null,
|
|
pylint: null,
|
|
golint: null
|
|
};
|
|
|
|
// Run ESLint if available
|
|
if (jsFiles.length > 0) {
|
|
const eslintBin = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
|
|
const eslintPath = path.join(process.cwd(), 'node_modules', '.bin', eslintBin);
|
|
if (fs.existsSync(eslintPath)) {
|
|
const result = spawnSync(eslintPath, ['--format', 'compact', ...jsFiles], {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: 30000
|
|
});
|
|
results.eslint = {
|
|
success: result.status === 0,
|
|
output: result.stdout || result.stderr
|
|
};
|
|
}
|
|
}
|
|
|
|
// Run Pylint if available
|
|
if (pyFiles.length > 0) {
|
|
try {
|
|
const result = spawnSync('pylint', ['--output-format=text', ...pyFiles], {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: 30000
|
|
});
|
|
if (result.error && result.error.code === 'ENOENT') {
|
|
results.pylint = null;
|
|
} else {
|
|
results.pylint = {
|
|
success: result.status === 0,
|
|
output: result.stdout || result.stderr
|
|
};
|
|
}
|
|
} catch {
|
|
// Pylint not available
|
|
}
|
|
}
|
|
|
|
// Run golint if available
|
|
if (goFiles.length > 0) {
|
|
try {
|
|
const result = spawnSync('golint', goFiles, {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: 30000
|
|
});
|
|
if (result.error && result.error.code === 'ENOENT') {
|
|
results.golint = null;
|
|
} else {
|
|
results.golint = {
|
|
success: !result.stdout || result.stdout.trim() === '',
|
|
output: result.stdout
|
|
};
|
|
}
|
|
} catch {
|
|
// golint not available
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Core logic — exported for direct invocation
|
|
* @param {string} rawInput - Raw JSON string from stdin
|
|
* @returns {{output:string, exitCode:number}} Pass-through output and exit code
|
|
*/
|
|
function evaluate(rawInput) {
|
|
try {
|
|
const input = JSON.parse(rawInput);
|
|
const command = input.tool_input?.command || '';
|
|
|
|
// Only run for git commit commands
|
|
if (!command.includes('git commit')) {
|
|
return { output: rawInput, exitCode: 0 };
|
|
}
|
|
|
|
// Check if this is an amend (skip checks for amends to avoid blocking)
|
|
if (command.includes('--amend')) {
|
|
return { output: rawInput, exitCode: 0 };
|
|
}
|
|
|
|
// Get staged files
|
|
const stagedFiles = getStagedFiles();
|
|
|
|
if (stagedFiles.length === 0) {
|
|
console.error('[Hook] No staged files found. Use "git add" to stage files first.');
|
|
return { output: rawInput, exitCode: 0 };
|
|
}
|
|
|
|
console.error(`[Hook] Checking ${stagedFiles.length} staged file(s)...`);
|
|
|
|
// Check each staged file
|
|
const filesToCheck = stagedFiles.filter(shouldCheckFile);
|
|
let totalIssues = 0;
|
|
let errorCount = 0;
|
|
let warningCount = 0;
|
|
let infoCount = 0;
|
|
|
|
for (const file of filesToCheck) {
|
|
const fileIssues = findFileIssues(file);
|
|
if (fileIssues.length > 0) {
|
|
console.error(`\n[FILE] ${file}`);
|
|
for (const issue of fileIssues) {
|
|
const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARNING' : 'INFO';
|
|
console.error(` ${label} Line ${issue.line}: ${issue.message}`);
|
|
totalIssues++;
|
|
if (issue.severity === 'error') errorCount++;
|
|
if (issue.severity === 'warning') warningCount++;
|
|
if (issue.severity === 'info') infoCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate commit message if provided
|
|
const messageValidation = validateCommitMessage(command);
|
|
if (messageValidation && messageValidation.issues.length > 0) {
|
|
console.error('\nCommit Message Issues:');
|
|
for (const issue of messageValidation.issues) {
|
|
console.error(` WARNING ${issue.message}`);
|
|
if (issue.suggestion) {
|
|
console.error(` TIP ${issue.suggestion}`);
|
|
}
|
|
totalIssues++;
|
|
warningCount++;
|
|
}
|
|
}
|
|
|
|
// Run linter
|
|
const lintResults = runLinter(filesToCheck);
|
|
|
|
if (lintResults.eslint && !lintResults.eslint.success) {
|
|
console.error('\nESLint Issues:');
|
|
console.error(lintResults.eslint.output);
|
|
totalIssues++;
|
|
errorCount++;
|
|
}
|
|
|
|
if (lintResults.pylint && !lintResults.pylint.success) {
|
|
console.error('\nPylint Issues:');
|
|
console.error(lintResults.pylint.output);
|
|
totalIssues++;
|
|
errorCount++;
|
|
}
|
|
|
|
if (lintResults.golint && !lintResults.golint.success) {
|
|
console.error('\ngolint Issues:');
|
|
console.error(lintResults.golint.output);
|
|
totalIssues++;
|
|
errorCount++;
|
|
}
|
|
|
|
// Summary
|
|
if (totalIssues > 0) {
|
|
console.error(`\nSummary: ${totalIssues} issue(s) found (${errorCount} error(s), ${warningCount} warning(s), ${infoCount} info)`);
|
|
|
|
if (errorCount > 0) {
|
|
console.error('\n[Hook] ERROR: Commit blocked due to critical issues. Fix them before committing.');
|
|
return { output: rawInput, exitCode: 2 };
|
|
} else {
|
|
console.error('\n[Hook] WARNING: Warnings found. Consider fixing them, but commit is allowed.');
|
|
console.error('[Hook] To bypass these checks, use: git commit --no-verify');
|
|
}
|
|
} else {
|
|
console.error('\n[Hook] PASS: All checks passed!');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`[Hook] Error: ${error.message}`);
|
|
// Non-blocking on error
|
|
}
|
|
|
|
return { output: rawInput, exitCode: 0 };
|
|
}
|
|
|
|
function run(rawInput) {
|
|
return evaluate(rawInput).output;
|
|
}
|
|
|
|
// ── stdin entry point ────────────────────────────────────────────
|
|
if (require.main === module) {
|
|
let data = '';
|
|
process.stdin.setEncoding('utf8');
|
|
|
|
process.stdin.on('data', chunk => {
|
|
if (data.length < MAX_STDIN) {
|
|
const remaining = MAX_STDIN - data.length;
|
|
data += chunk.substring(0, remaining);
|
|
}
|
|
});
|
|
|
|
process.stdin.on('end', () => {
|
|
const result = evaluate(data);
|
|
process.stdout.write(result.output);
|
|
process.exit(result.exitCode);
|
|
});
|
|
}
|
|
|
|
module.exports = { run, evaluate };
|