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.
This commit is contained in:
264
.hooks/issue-linter.js
Normal file
264
.hooks/issue-linter.js
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user