- docs/workflow/: INDEX, ISSUE-FORMAT, WORKFLOW, AGENT-PROMPTS - .issues/: INDEX, example issue - .hooks/: issue-linter.js (pre-commit validator) - package.json with setup script - README.md See docs/workflow/INDEX.md to get started.
265 lines
7.3 KiB
JavaScript
265 lines
7.3 KiB
JavaScript
#!/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 };
|