Files
workflow/.hooks/issue-linter.js
shokollm 3f12cd1072 Initial workflow template
- 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.
2026-04-18 12:33:24 +00:00

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 };