automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
604 lines (536 loc) • 18 kB
JavaScript
/**
* Commit Advisory Analyzer
*
* Validates every commit is traced to work items (wishes/GitHub issues)
* and aligned with Genie framework.
*
* Exit codes:
* - 0: All validations passed
* - 1: Warnings only (user can override)
* - 2: Blocking errors (push blocked)
* - 3: Script error (git failed, etc)
*/
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const WISHES_DIR = path.join(REPO_ROOT, '.genie', 'wishes');
class CommitAdvisory {
constructor() {
this.errors = [];
this.warnings = [];
this.passes = [];
this.commits = [];
this.wishes = new Map();
this.issues = new Set();
}
log(color, emoji, msg) {
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
console.log(`${colors[color] || ''}${emoji} ${msg}${colors.reset}`);
}
/**
* Get commits to be pushed
*/
getCommitsToPush() {
const delimiter = '---COMMIT-BOUNDARY---';
const format = `%H%n%s%n%b${delimiter}`;
try {
let log;
// Strategy 1: Try to get commits relative to upstream (@{u})
try {
log = execSync(`git log @{u}..HEAD --format="${format}"`, {
encoding: 'utf8',
cwd: REPO_ROOT,
stdio: ['pipe', 'pipe', 'ignore']
});
if (log.trim()) {
return this.parseCommitLog(log, delimiter);
}
} catch {
// Upstream doesn't exist yet (branch not pushed)
}
// Strategy 2: Try commits relative to origin/main
try {
log = execSync(`git log origin/main..HEAD --format="${format}"`, {
encoding: 'utf8',
cwd: REPO_ROOT,
stdio: ['pipe', 'pipe', 'ignore']
});
if (log.trim()) {
return this.parseCommitLog(log, delimiter);
}
} catch {
// origin/main doesn't exist
}
// Strategy 3: Try commits relative to local main
try {
log = execSync(`git log main..HEAD --format="${format}"`, {
encoding: 'utf8',
cwd: REPO_ROOT,
stdio: ['pipe', 'pipe', 'ignore']
});
if (log.trim()) {
return this.parseCommitLog(log, delimiter);
}
} catch {
// main doesn't exist
}
// Strategy 4: Find merge-base and get commits from there
try {
const mergeBase = execSync('git merge-base HEAD origin/main', {
encoding: 'utf8',
cwd: REPO_ROOT,
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
if (mergeBase) {
log = execSync(`git log ${mergeBase}..HEAD --format="${format}"`, {
encoding: 'utf8',
cwd: REPO_ROOT
});
if (log.trim()) {
return this.parseCommitLog(log, delimiter);
}
}
} catch {
// merge-base failed
}
// Final fallback: last 5 commits (with warning)
this.warnings.push('Could not determine base branch - checking last 5 commits only');
log = execSync(`git log -5 --format="${format}"`, {
encoding: 'utf8',
cwd: REPO_ROOT
});
return this.parseCommitLog(log, delimiter);
} catch (e) {
this.errors.push(`Failed to get commits: ${e.message}`);
return [];
}
}
/**
* Parse commit log output into commit objects
*/
parseCommitLog(log, delimiter) {
const entries = log.split(delimiter).filter(e => e.trim());
if (entries.length === 0) {
this.warnings.push('No commits found in recent history');
return [];
}
return entries.map(entry => {
const lines = entry.trim().split('\n');
return {
hash: lines[0] || '',
subject: lines[1] || 'unknown',
body: lines.slice(2).join('\n').trim()
};
});
}
/**
* Get current branch
*/
getCurrentBranch() {
try {
return execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
cwd: REPO_ROOT
}).trim();
} catch {
return 'unknown';
}
}
/**
* Get files changed in commits
*/
getChangedFiles() {
try {
try {
const files = execSync('git diff @{u}..HEAD --name-only', {
encoding: 'utf8',
cwd: REPO_ROOT,
stdio: ['pipe', 'pipe', 'ignore']
});
return files.trim().split('\n').filter(f => f);
} catch {
// Fallback: changed files in working tree
const files = execSync('git diff HEAD~5..HEAD --name-only', {
encoding: 'utf8',
cwd: REPO_ROOT
});
return files.trim().split('\n').filter(f => f);
}
} catch {
return [];
}
}
/**
* Load all wishes from .genie/wishes/
*/
loadWishes() {
if (!fs.existsSync(WISHES_DIR)) {
return;
}
const dirs = fs.readdirSync(WISHES_DIR);
for (const dir of dirs) {
if (dir.startsWith('_')) continue; // Skip archives
const wishFile = path.join(WISHES_DIR, dir, `${dir}-wish.md`);
if (fs.existsSync(wishFile)) {
const content = fs.readFileSync(wishFile, 'utf8');
this.wishes.set(dir, {
path: wishFile,
content,
files: this.extractFilesFromWish(content)
});
}
}
}
/**
* Extract file list from wish document
*/
extractFilesFromWish(content) {
const files = new Set();
// Look for sections: ## Scope, Files:, Modified:, Touched:
const patterns = [
/##\s+Scope[\s\S]*?(?=##|\Z)/,
/Files:?\s*[\s\S]*?(?=##|\Z)/,
/Modified:?\s*[\s\S]*?(?=##|\Z)/,
/Touched:?\s*[\s\S]*?(?=##|\Z)/
];
for (const pattern of patterns) {
const match = content.match(pattern);
if (match) {
// Extract file paths (common patterns)
const lines = match[0].split('\n');
for (const line of lines) {
// Match: `src/foo.ts`, `- src/bar.js`, etc
const fileMatch = line.match(/(?:[-*]?\s*)?(`?[\w/.@-]+\.\w+`?)/g);
if (fileMatch) {
fileMatch.forEach(f => {
const clean = f.replace(/[-*`\s]/g, '').trim();
if (clean && !clean.includes('##')) files.add(clean);
});
}
}
}
}
return Array.from(files);
}
/**
* Extract references from commit (wish:, fixes #, closes #, etc)
*/
extractReferences(commit) {
const full = `${commit.subject} ${commit.body}`;
const refs = {
wishes: [],
issues: [],
hasWishRef: false,
hasIssueRef: false
};
// Look for wish references: wish: slug, .genie/wishes/slug
// Require colon for "wish:" format to avoid false positives like "wish or"
const wishPattern = /\bwish:\s*(\w[-\w]+)|\.genie\/wishes\/(\w[-\w]+)/gim;
let wishMatch;
while ((wishMatch = wishPattern.exec(full)) !== null) {
const slug = wishMatch[1] || wishMatch[2];
if (slug) {
refs.wishes.push(slug);
refs.hasWishRef = true;
}
}
// Look for GitHub issues: fixes #123, closes #456
const issuePattern = /(fixes|closes)\s+#(\d+)/gi;
let issueMatch;
while ((issueMatch = issuePattern.exec(full)) !== null) {
const num = issueMatch[2];
if (num) {
refs.issues.push(num);
refs.hasIssueRef = true;
this.issues.add(num);
}
}
return refs;
}
/**
* Check if commit is a bug fix
*/
isBugFix(commit) {
return /^fix:|^bug:|bug fix/i.test(commit.subject);
}
/**
* Check if commit is exempt from traceability requirements
* Includes: automated commits, chores, and non-functional enhancements
*/
isAutomatedCommit(commit) {
const full = `${commit.subject} ${commit.body}`;
// Patterns for automated commits and exempt commit types
const patterns = [
// Auto-generated commits
/\(auto-generated\)/i,
/\[skip ci\]/i,
/\[auto\]/i,
/agent graph/i,
/\(automagik-forge\s+[a-f0-9-]+\)/i, // Forge task references
// Exempt commit types (don't require issues)
/^chore:/i, // All maintenance tasks
/^perf:/i, // Performance improvements (speed)
/^refactor:/i, // Code quality (reduce LOC, cleanup)
/^style:/i, // Formatting/style changes
/^docs:/i // Documentation updates
];
return patterns.some(pattern => pattern.test(full));
}
/**
* Validate commits
*/
validateCommits(isReleaseBranch = false) {
if (this.commits.length === 0) {
this.passes.push('No new commits to validate');
return;
}
const references = new Map();
// Extract all references first
for (const commit of this.commits) {
const refs = this.extractReferences(commit);
references.set(commit.hash, refs);
}
// Release branch: Skip traceability check, add validation pass
if (isReleaseBranch) {
this.passes.push('Release branch - traceability validation skipped (expected for automated releases)');
return;
}
// Rule 2: Commit Traceability (BLOCKING)
let exemptCount = 0;
for (const commit of this.commits) {
// Skip automated/forge commits from traceability check
if (this.isAutomatedCommit(commit)) {
exemptCount++;
continue;
}
const refs = references.get(commit.hash);
if (!refs.hasWishRef && !refs.hasIssueRef) {
this.errors.push({
commit: commit.hash.substring(0, 8),
subject: commit.subject.substring(0, 60),
reason: 'Not linked to wish or GitHub issue',
why: 'Every commit must trace to a work item (wish or issue) so we can track WHY code was written',
fixes: [
{
label: 'Link to existing wish',
command: `git commit --amend -m "${commit.subject.substring(0, 40)}\n\nwish: wish-slug"`,
description: 'Replace "wish-slug" with an actual wish slug from .genie/wishes/'
},
{
label: 'Link to GitHub issue',
command: `git commit --amend -m "${commit.subject.substring(0, 40)}\n\nfixes #NNN"`,
description: 'Replace NNN with actual GitHub issue number'
}
]
});
}
}
// Report exempt commits
if (exemptCount > 0) {
this.passes.push(`${exemptCount} commit(s) exempt from traceability (chore/perf/refactor/style/docs/automated)`);
}
// Rule 3: Bug Commits Must Have Issues (BLOCKING)
for (const commit of this.commits) {
// Skip automated/forge commits from bug fix check
if (this.isAutomatedCommit(commit)) {
continue;
}
if (this.isBugFix(commit)) {
const refs = references.get(commit.hash);
if (!refs.hasIssueRef) {
this.errors.push({
commit: commit.hash.substring(0, 8),
subject: commit.subject.substring(0, 60),
reason: 'Bug fix without GitHub issue reference',
why: 'Bug fixes must link to GitHub issues for traceability and audit trail',
fixes: [
{
label: 'Create GitHub issue first',
command: `gh issue create -t "Bug: ${commit.subject.substring(0, 40)}" -b "Description of the bug and fix"`,
description: 'Create the issue, note the number (e.g., #42), then amend commit'
},
{
label: 'Then link in commit',
command: `git commit --amend -m "${commit.subject.substring(0, 40)}\n\nfixes #NNN"`,
description: 'Replace NNN with the issue number from previous step'
}
]
});
}
}
}
// Rule 5: Multiple Wishes (WARNING)
const wishSet = new Set();
for (const refs of references.values()) {
refs.wishes.forEach(w => wishSet.add(w));
}
if (wishSet.size > 1) {
this.warnings.push(
`Commits reference multiple wishes: ${Array.from(wishSet).join(', ')}\n` +
` Suggestion: Keep work focused to single wish per push`
);
}
// Rule 4: Wish Alignment (WARNING)
if (wishSet.size === 1) {
const wishSlug = Array.from(wishSet)[0];
const wish = this.wishes.get(wishSlug);
if (wish) {
const changedFiles = this.getChangedFiles();
const wishFiles = wish.files;
if (wishFiles.length > 0) {
const overlap = changedFiles.filter(f =>
wishFiles.some(wf => f.includes(wf) || wf.includes(f))
).length;
const alignmentRatio = overlap / Math.max(wishFiles.length, 1);
if (alignmentRatio < 0.3) {
this.warnings.push(
`Commits don't align well with wish "${wishSlug}" scope\n` +
` Files touched: ${changedFiles.length}, wish files: ${wishFiles.length}, overlap: ${overlap}\n` +
` Suggestion: Verify wish is current/active`
);
} else {
this.passes.push(`Commits aligned with wish "${wishSlug}" (${Math.round(alignmentRatio * 100)}%)`);
}
}
}
}
// Summary
if (references.size > 0) {
const allWishes = Array.from(wishSet);
const allIssues = Array.from(this.issues);
if (allWishes.length > 0) {
this.passes.push(`Commits traced to wishes: ${allWishes.join(', ')}`);
}
if (allIssues.length > 0) {
this.passes.push(`Commits traced to GitHub issues: ${allIssues.map(n => `#${n}`).join(', ')}`);
}
}
}
/**
* Check branch safety
*/
validateBranch() {
const branch = this.getCurrentBranch();
if (branch === 'main' || branch === 'master') {
this.warnings.push(
`Pushing to "${branch}" branch directly\n` +
` Suggestion: Use feature branch (feat/wish-slug) for traced work\n` +
` Override: Set GENIE_ALLOW_MAIN_PUSH=1`
);
}
}
/**
* Generate advisory report with remediation guide
*/
generateReport(branch, commitCount) {
let report = [];
report.push('# Pre-Push Commit Advisory\n');
report.push(`**Branch:** ${branch}`);
report.push(`**Commits:** ${commitCount} new commit(s)`);
report.push(`**Wishes Referenced:** ${Array.from(new Set(this.commits.flatMap(c =>
this.extractReferences(c).wishes))).join(', ') || 'none'}`);
report.push(`**GitHub Issues:** ${Array.from(this.issues).map(n => `#${n}`).join(', ') || 'none'}`);
report.push('');
if (this.errors.length > 0) {
report.push(`## ❌ Blocking Issues (${this.errors.length})\n`);
this.errors.forEach((err, i) => {
if (typeof err === 'object') {
report.push(`${i + 1}. **${err.reason}**`);
report.push(` Commit: \`${err.commit}\` - ${err.subject}`);
report.push(` WHY: ${err.why}`);
report.push(` HOW TO FIX (choose one):\n`);
err.fixes.forEach((fix, j) => {
report.push(` **${fix.label}:**`);
report.push(` \`\`\``);
report.push(` ${fix.command}`);
report.push(` \`\`\``);
report.push(` ${fix.description}`);
});
report.push('');
} else {
report.push(`${i + 1}. ${err}\n`);
}
});
// Add quick fix section
report.push('## 📋 Quick Fix Steps\n');
report.push('1. Choose ONE of the fix options above for each error');
report.push('2. Run the command from that option');
report.push('3. After amending: `git push`\n');
report.push('**Need help?** See: `.genie/docs/commit-advisory-guide.md`\n');
}
if (this.warnings.length > 0) {
report.push(`## ⚠️ Warnings (${this.warnings.length})\n`);
this.warnings.forEach((warn, i) => {
report.push(`${i + 1}. ${warn}\n`);
});
}
if (this.passes.length > 0 && this.errors.length === 0) {
report.push(`## ✅ Passed\n`);
this.passes.forEach(pass => {
report.push(`- ${pass}`);
});
report.push('');
}
report.push(`**Generated:** ${new Date().toISOString()}`);
return report.join('\n');
}
/**
* Determine exit code based on validation results
*/
getExitCode() {
if (this.errors.length > 0) return 2; // Blocking
if (this.warnings.length > 0) return 1; // Warnings
return 0; // Pass
}
/**
* Run full analysis
*/
async run() {
this.log('cyan', '🧞', 'Running commit advisory...\n');
const branch = this.getCurrentBranch();
this.commits = this.getCommitsToPush();
if (this.commits.length === 0) {
this.passes.push('No commits to analyze (working tree clean).');
const report = this.generateReport(branch, 0);
console.log(report);
this.log('yellow', '⚠️ ', 'No commits to analyze');
return 0;
}
// Check if on release branch (feat/release-*)
const isReleaseBranch = branch.startsWith('feat/release-');
this.loadWishes();
this.validateBranch();
this.validateCommits(isReleaseBranch);
const report = this.generateReport(branch, this.commits.length);
console.log(report);
const exitCode = this.getExitCode();
if (exitCode === 0) {
this.log('green', '✅', 'All validations passed\n');
} else if (exitCode === 1) {
this.log('yellow', '⚠️ ', 'Push has warnings (can override: GENIE_SKIP_WISH_CHECK=1 git push)\n');
} else if (exitCode === 2) {
this.log('red', '❌', 'Push blocked - fix errors before pushing\n');
}
return exitCode;
}
}
// Main
(async () => {
try {
const advisory = new CommitAdvisory();
const exitCode = await advisory.run();
process.exit(exitCode);
} catch (e) {
console.error('❌ Advisory error:', e.message);
process.exit(3);
}
})();