#!/usr/bin/env node /** * Issue Linter - Pre-commit hook validator * * Runs before every commit to ensure issue files are valid. * * Usage: * node .hooks/issue-linter.js * * Exit codes: * 0 - All checks passed * 1 - Validation errors found */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Configuration const ISSUES_DIR = '.issues'; const REQUIRED_FRONTMATTER = ['id', 'title', 'status', 'priority', 'created']; const VALID_STATUSES = ['open', 'in-progress', 'done', 'blocked']; const VALID_PRIORITIES = ['low', 'medium', 'high', 'critical']; const REQUIRED_SECTIONS = ['What', 'Why', 'Acceptance Criteria', 'Verification']; // Colors for output const RED = '\x1b[31m'; const GREEN = '\x1b[32m'; const YELLOW = '\x1b[33m'; const RESET = '\x1b[0m'; let errors = []; let warnings = []; function logError(msg) { errors.push(msg); console.error(`${RED}[issue-linter] ERROR: ${msg}${RESET}`); } function logWarning(msg) { warnings.push(msg); console.warn(`${YELLOW}[issue-linter] WARNING: ${msg}${RESET}`); } function logInfo(msg) { console.log(`${GREEN}[issue-linter] ${msg}${RESET}`); } /** * Parse YAML frontmatter from markdown content */ function parseFrontmatter(content) { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return {}; const frontmatter = {}; const lines = match[1].split('\n'); for (const line of lines) { const colonIndex = line.indexOf(':'); if (colonIndex === -1) continue; const key = line.slice(0, colonIndex).trim(); let value = line.slice(colonIndex + 1).trim(); // Handle arrays (depends-on) if (value.startsWith('[') && value.endsWith(']')) { value = value.slice(1, -1).split(',').map(v => v.trim()); } frontmatter[key] = value; } return frontmatter; } /** * Check if a section exists in the content */ function sectionExists(content, sectionName) { // Look for ## Section Name or ### Section Name const regex = new RegExp(`^#{2,3}\\s+${sectionName}\\s*$`, 'm'); return regex.test(content); } /** * Get all files that were changed in this commit */ function getChangedFiles() { try { const output = execSync('git diff --cached --name-only', { encoding: 'utf8' }); return output.trim().split('\n').filter(f => f.length > 0); } catch (e) { // Fallback for when git commands fail return []; } } /** * Get the staging area (added files) */ function getStagedFiles() { try { const output = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }); return output.trim().split('\n').filter(f => f.length > 0); } catch (e) { return []; } } /** * Get all issue files in .issues/ */ function getIssueFiles() { const issuesPath = path.join(process.cwd(), ISSUES_DIR); if (!fs.existsSync(issuesPath)) { return []; } const files = fs.readdirSync(issuesPath, { recursive: true }); return files .filter(f => f.endsWith('.md')) .map(f => path.join(ISSUES_DIR, f)); } /** * Validate a single issue file */ function validateIssueFile(filePath) { const fullPath = path.join(process.cwd(), filePath); if (!fs.existsSync(fullPath)) { logError(`${filePath} does not exist`); return false; } const content = fs.readFileSync(fullPath, 'utf8'); const frontmatter = parseFrontmatter(content); let hasErrors = false; // Check required frontmatter for (const field of REQUIRED_FRONTMATTER) { if (!(field in frontmatter)) { logError(`${filePath}: Missing frontmatter field "${field}"`); hasErrors = true; } } // Check status is valid if (frontmatter.status && !VALID_STATUSES.includes(frontmatter.status)) { logError(`${filePath}: Invalid status "${frontmatter.status}". Must be one of: ${VALID_STATUSES.join(', ')}`); hasErrors = true; } // Check priority is valid if (frontmatter.priority && !VALID_PRIORITIES.includes(frontmatter.priority)) { logError(`${filePath}: Invalid priority "${frontmatter.priority}". Must be one of: ${VALID_PRIORITIES.join(', ')}`); hasErrors = true; } // Check completed date if status is done if (frontmatter.status === 'done' && !frontmatter.completed) { logError(`${filePath}: Has status "done" but no "completed" date in frontmatter`); hasErrors = true; } // Check required sections for (const section of REQUIRED_SECTIONS) { if (!sectionExists(content, section)) { logError(`${filePath}: Missing section "## ${section}"`); hasErrors = true; } } // Check Plan exists if status is in-progress if (frontmatter.status === 'in-progress' && !sectionExists(content, 'Plan')) { logError(`${filePath}: Has status "in-progress" but no Plan section`); hasErrors = true; } // Check Agent Working Notes is deleted if status is done if (frontmatter.status === 'done' && sectionExists(content, 'Agent Working Notes')) { logError(`${filePath}: Has status "done" but Agent Working Notes section not deleted`); hasErrors = true; } return !hasErrors; } /** * Check if code was changed without a corresponding issue file */ function checkCodeWithoutIssue(stagedFiles) { const codeExtensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.rb', '.php', '.cs', '.swift', '.kt', '.scala', '.vue', '.svelte']; const codeFiles = stagedFiles.filter(f => { const ext = path.extname(f); return codeExtensions.includes(ext) || f.startsWith('src/') || f.startsWith('lib/') || f.startsWith('app/'); }); const issueFiles = getIssueFiles(); for (const codeFile of codeFiles) { // Check if there's a related issue file // Extract potential issue number from recent commits or check .issues/INDEX.md // For simplicity, we just warn if there are code changes if (issueFiles.length === 0) { logWarning(`${codeFile}: Code changed but no issue files found`); } } } /** * Main validation */ function main() { console.log('[issue-linter] Running pre-commit validation...\n'); const stagedFiles = getStagedFiles(); const issueFiles = getIssueFiles(); // If no issues exist yet (first time setup), skip issue validation if (issueFiles.length === 0) { logInfo('No issue files found. Skipping issue validation.'); logInfo('Add issue files to .issues/ directory before committing code.'); process.exit(0); } // Validate all issue files (not just staged ones) let allValid = true; for (const file of issueFiles) { if (!validateIssueFile(file)) { allValid = false; } } // Check for code changes without issues checkCodeWithoutIssue(stagedFiles); // Summary console.log(''); if (errors.length > 0) { console.error(`${RED}[issue-linter] FAIL: ${errors.length} error(s) found${RESET}`); if (warnings.length > 0) { console.warn(`${YELLOW}[issue-linter] ${warnings.length} warning(s)${RESET}`); } process.exit(1); } else if (warnings.length > 0) { console.warn(`${YELLOW}[issue-linter] PASS with warnings: ${warnings.length} warning(s)${RESET}`); process.exit(0); } else { logInfo('PASS: All issue files are valid'); process.exit(0); } } // Run if called directly if (require.main === module) { main(); } module.exports = { validateIssueFile, parseFrontmatter };