419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
/**
|
|
* Guidance Compiler
|
|
*
|
|
* Parses root CLAUDE.md and optional CLAUDE.local.md into a compiled policy bundle:
|
|
* 1. A small always-loaded constitution (first 30-60 lines of invariants)
|
|
* 2. A set of task-scoped rule shards tagged by intent, risk, domain, repo path, tool class
|
|
* 3. A machine-readable manifest with rule IDs, triggers, and verifiers
|
|
*
|
|
* @module @claude-flow/guidance/compiler
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
// ============================================================================
|
|
// Parser Patterns
|
|
// ============================================================================
|
|
/** Matches a rule declaration: "R001:" or "RULE-001:" or "[R001]" or "- [R001]" */
|
|
const RULE_ID_PATTERN = /^(?:#{1,4}\s+)?(?:[-*]\s+)?\[?([A-Z]+-?\d{3,4})\]?[:\s]/;
|
|
/** Matches risk class annotations: "(critical)", "[high-risk]", etc. */
|
|
const RISK_PATTERN = /\(?(critical|high|medium|low|info)(?:-risk)?\)?/i;
|
|
/** Matches domain tags: @security, @testing, etc. */
|
|
const DOMAIN_TAG_PATTERN = /@(security|testing|performance|architecture|debugging|deployment|general)/gi;
|
|
/** Matches tool class tags: [edit], [bash], etc. */
|
|
const TOOL_TAG_PATTERN = /\[(edit|bash|read|write|mcp|task|all)\]/gi;
|
|
/** Matches intent tags: #bug-fix, #feature, etc. */
|
|
const INTENT_TAG_PATTERN = /#(bug-fix|feature|refactor|security|performance|testing|docs|deployment|architecture|debug|general)/gi;
|
|
/** Matches repo scope: scope:src/**, scope:tests/**, etc. */
|
|
const SCOPE_PATTERN = /scope:([\w\/\*\.\-]+)/gi;
|
|
/** Matches verifier annotations: verify:tests-pass, verify:lint-clean */
|
|
const VERIFIER_PATTERN = /verify:([\w\-]+)/i;
|
|
/** Matches priority override: priority:N */
|
|
const PRIORITY_PATTERN = /priority:(\d+)/i;
|
|
/** Section markers for constitution identification */
|
|
const CONSTITUTION_MARKERS = [
|
|
/^#+\s*(safety|security|invariant|constitution|critical|non[- ]?negotiable|always)/i,
|
|
/^#+\s*(must|never|always|required|mandatory)/i,
|
|
];
|
|
/** Section markers for shard boundaries */
|
|
const SHARD_MARKERS = [
|
|
/^#+\s/, // Any heading
|
|
/^---+\s*$/, // Horizontal rule
|
|
/^\*\*\*+\s*$/, // Bold horizontal rule
|
|
];
|
|
const DEFAULT_CONFIG = {
|
|
maxConstitutionLines: 60,
|
|
defaultRiskClass: 'medium',
|
|
defaultPriority: 50,
|
|
autoGenerateIds: true,
|
|
};
|
|
// ============================================================================
|
|
// Guidance Compiler
|
|
// ============================================================================
|
|
export class GuidanceCompiler {
|
|
config;
|
|
nextAutoId = 1;
|
|
constructor(config = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
}
|
|
/**
|
|
* Compile guidance files into a policy bundle
|
|
*/
|
|
compile(rootContent, localContent) {
|
|
// Parse both files into raw rules
|
|
const rootRules = this.parseGuidanceFile(rootContent, 'root');
|
|
const localRules = localContent
|
|
? this.parseGuidanceFile(localContent, 'local')
|
|
: [];
|
|
// Merge rules (local overrides root for same ID)
|
|
const allRules = this.mergeRules(rootRules, localRules);
|
|
// Split into constitution and shards
|
|
const constitutionRules = allRules.filter(r => r.isConstitution);
|
|
const shardRules = allRules.filter(r => !r.isConstitution);
|
|
// Build constitution
|
|
const constitution = this.buildConstitution(constitutionRules);
|
|
// Build shards
|
|
const shards = this.buildShards(shardRules);
|
|
// Build manifest
|
|
const manifest = this.buildManifest(allRules, rootContent, localContent);
|
|
return { constitution, shards, manifest };
|
|
}
|
|
/**
|
|
* Parse a guidance markdown file into rules
|
|
*/
|
|
parseGuidanceFile(content, source) {
|
|
const rules = [];
|
|
const lines = content.split('\n');
|
|
let currentSection = '';
|
|
let currentBlock = [];
|
|
let inConstitutionSection = false;
|
|
let blockStartLine = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
// Detect section boundaries
|
|
if (SHARD_MARKERS.some(m => m.test(line))) {
|
|
// Flush current block
|
|
if (currentBlock.length > 0) {
|
|
const blockRules = this.extractRulesFromBlock(currentBlock.join('\n'), source, inConstitutionSection, currentSection);
|
|
rules.push(...blockRules);
|
|
currentBlock = [];
|
|
}
|
|
// Check if this is a constitution section
|
|
inConstitutionSection = CONSTITUTION_MARKERS.some(m => m.test(line));
|
|
currentSection = line.replace(/^#+\s*/, '').trim();
|
|
blockStartLine = i;
|
|
}
|
|
currentBlock.push(line);
|
|
}
|
|
// Flush last block
|
|
if (currentBlock.length > 0) {
|
|
const blockRules = this.extractRulesFromBlock(currentBlock.join('\n'), source, inConstitutionSection, currentSection);
|
|
rules.push(...blockRules);
|
|
}
|
|
return rules;
|
|
}
|
|
/**
|
|
* Extract rules from a content block
|
|
*/
|
|
extractRulesFromBlock(block, source, isConstitution, section) {
|
|
const rules = [];
|
|
const lines = block.split('\n');
|
|
// Try to extract explicit rules (with IDs)
|
|
let ruleBuffer = [];
|
|
let currentRuleId = null;
|
|
for (const line of lines) {
|
|
const idMatch = line.match(RULE_ID_PATTERN);
|
|
if (idMatch) {
|
|
// Flush previous rule
|
|
if (currentRuleId && ruleBuffer.length > 0) {
|
|
rules.push(this.parseRule(currentRuleId, ruleBuffer.join('\n'), source, isConstitution));
|
|
ruleBuffer = [];
|
|
}
|
|
currentRuleId = idMatch[1];
|
|
ruleBuffer.push(line.replace(RULE_ID_PATTERN, '').trim());
|
|
}
|
|
else if (currentRuleId) {
|
|
ruleBuffer.push(line);
|
|
}
|
|
}
|
|
// Flush last rule
|
|
if (currentRuleId && ruleBuffer.length > 0) {
|
|
rules.push(this.parseRule(currentRuleId, ruleBuffer.join('\n'), source, isConstitution));
|
|
}
|
|
// If no explicit rules found, try to extract implicit rules from bullet points
|
|
if (rules.length === 0) {
|
|
const implicitRules = this.extractImplicitRules(block, source, isConstitution, section);
|
|
rules.push(...implicitRules);
|
|
}
|
|
return rules;
|
|
}
|
|
/**
|
|
* Extract implicit rules from bullet points and paragraphs
|
|
*/
|
|
extractImplicitRules(block, source, isConstitution, section) {
|
|
const rules = [];
|
|
const lines = block.split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
// Skip empty lines, headings, and non-actionable content
|
|
if (!trimmed || /^#+\s/.test(trimmed) || /^---/.test(trimmed))
|
|
continue;
|
|
// Match actionable bullet points
|
|
const bulletMatch = trimmed.match(/^[-*]\s+(.+)/);
|
|
if (!bulletMatch)
|
|
continue;
|
|
const text = bulletMatch[1].trim();
|
|
// Only create rules for actionable statements
|
|
if (!this.isActionableRule(text))
|
|
continue;
|
|
// Auto-generate ID if enabled
|
|
if (this.config.autoGenerateIds) {
|
|
const id = `AUTO-${String(this.nextAutoId++).padStart(3, '0')}`;
|
|
rules.push(this.parseRule(id, text, source, isConstitution));
|
|
}
|
|
}
|
|
return rules;
|
|
}
|
|
/**
|
|
* Check if text represents an actionable rule
|
|
*/
|
|
isActionableRule(text) {
|
|
const actionPatterns = [
|
|
/\b(must|never|always|should|require|forbid|ensure|validate|check|verify)\b/i,
|
|
/\b(do not|don't|cannot|can't|avoid|prevent|block|deny|reject)\b/i,
|
|
/\b(use|prefer|apply|follow|implement|enforce|maintain|keep|run|include|write|mock|respect)\b/i,
|
|
];
|
|
return actionPatterns.some(p => p.test(text));
|
|
}
|
|
/**
|
|
* Parse a single rule from its text content
|
|
*/
|
|
parseRule(id, text, source, isConstitution) {
|
|
const now = Date.now();
|
|
// Extract risk class
|
|
const riskMatch = text.match(RISK_PATTERN);
|
|
const riskClass = riskMatch?.[1]?.toLowerCase() ?? this.config.defaultRiskClass;
|
|
// Extract tool classes
|
|
const toolClasses = [];
|
|
let toolMatch;
|
|
const toolRegex = new RegExp(TOOL_TAG_PATTERN.source, 'gi');
|
|
while ((toolMatch = toolRegex.exec(text)) !== null) {
|
|
toolClasses.push(toolMatch[1].toLowerCase());
|
|
}
|
|
if (toolClasses.length === 0)
|
|
toolClasses.push('all');
|
|
// Extract intents
|
|
const intents = [];
|
|
let intentMatch;
|
|
const intentRegex = new RegExp(INTENT_TAG_PATTERN.source, 'gi');
|
|
while ((intentMatch = intentRegex.exec(text)) !== null) {
|
|
intents.push(intentMatch[1].toLowerCase());
|
|
}
|
|
if (intents.length === 0) {
|
|
intents.push(...this.inferIntents(text));
|
|
}
|
|
// Extract domains
|
|
const domains = [];
|
|
let domainMatch;
|
|
const domainRegex = new RegExp(DOMAIN_TAG_PATTERN.source, 'gi');
|
|
while ((domainMatch = domainRegex.exec(text)) !== null) {
|
|
domains.push(domainMatch[1].toLowerCase());
|
|
}
|
|
if (domains.length === 0) {
|
|
domains.push(...this.inferDomains(text));
|
|
}
|
|
// Extract repo scopes
|
|
const repoScopes = [];
|
|
let scopeMatch;
|
|
const scopeRegex = new RegExp(SCOPE_PATTERN.source, 'gi');
|
|
while ((scopeMatch = scopeRegex.exec(text)) !== null) {
|
|
repoScopes.push(scopeMatch[1]);
|
|
}
|
|
if (repoScopes.length === 0)
|
|
repoScopes.push('**/*');
|
|
// Extract verifier
|
|
const verifierMatch = text.match(VERIFIER_PATTERN);
|
|
const verifier = verifierMatch?.[1];
|
|
// Extract priority
|
|
const priorityMatch = text.match(PRIORITY_PATTERN);
|
|
const priority = priorityMatch ? parseInt(priorityMatch[1], 10) : this.config.defaultPriority;
|
|
// Clean rule text (remove annotations)
|
|
const cleanText = text
|
|
.replace(RISK_PATTERN, '')
|
|
.replace(TOOL_TAG_PATTERN, '')
|
|
.replace(INTENT_TAG_PATTERN, '')
|
|
.replace(DOMAIN_TAG_PATTERN, '')
|
|
.replace(SCOPE_PATTERN, '')
|
|
.replace(VERIFIER_PATTERN, '')
|
|
.replace(PRIORITY_PATTERN, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return {
|
|
id,
|
|
text: cleanText,
|
|
riskClass,
|
|
toolClasses,
|
|
intents,
|
|
repoScopes,
|
|
domains,
|
|
priority: isConstitution ? priority + 100 : priority,
|
|
source,
|
|
isConstitution,
|
|
verifier,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
/**
|
|
* Infer intents from rule text
|
|
*/
|
|
inferIntents(text) {
|
|
const intents = [];
|
|
const lower = text.toLowerCase();
|
|
if (/secur|auth|secret|password|token|cve|vuln|encrypt/i.test(lower))
|
|
intents.push('security');
|
|
if (/test|spec|mock|coverage|assert|tdd/i.test(lower))
|
|
intents.push('testing');
|
|
if (/perf|optim|fast|slow|cache|memory|speed/i.test(lower))
|
|
intents.push('performance');
|
|
if (/refactor|clean|restructur|simplif/i.test(lower))
|
|
intents.push('refactor');
|
|
if (/bug|fix|error|broken|fail|debug/i.test(lower))
|
|
intents.push('bug-fix');
|
|
if (/architect|design|pattern|structure|boundary/i.test(lower))
|
|
intents.push('architecture');
|
|
if (/deploy|release|publish|ci|cd/i.test(lower))
|
|
intents.push('deployment');
|
|
if (/doc|readme|comment|jsdoc/i.test(lower))
|
|
intents.push('docs');
|
|
return intents.length > 0 ? intents : ['general'];
|
|
}
|
|
/**
|
|
* Infer domains from rule text
|
|
*/
|
|
inferDomains(text) {
|
|
const domains = [];
|
|
const lower = text.toLowerCase();
|
|
if (/secur|auth|secret|password|token|cve|vuln/i.test(lower))
|
|
domains.push('security');
|
|
if (/test|spec|mock|coverage|assert/i.test(lower))
|
|
domains.push('testing');
|
|
if (/perf|optim|fast|slow|cache|speed/i.test(lower))
|
|
domains.push('performance');
|
|
if (/architect|design|ddd|domain|boundary/i.test(lower))
|
|
domains.push('architecture');
|
|
if (/bug|fix|error|debug/i.test(lower))
|
|
domains.push('debugging');
|
|
return domains.length > 0 ? domains : ['general'];
|
|
}
|
|
/**
|
|
* Merge root and local rules, local overrides root for same ID
|
|
*/
|
|
mergeRules(rootRules, localRules) {
|
|
const ruleMap = new Map();
|
|
for (const rule of rootRules) {
|
|
ruleMap.set(rule.id, rule);
|
|
}
|
|
for (const rule of localRules) {
|
|
if (ruleMap.has(rule.id)) {
|
|
// Local overrides root, but mark as updated
|
|
const existing = ruleMap.get(rule.id);
|
|
ruleMap.set(rule.id, {
|
|
...rule,
|
|
priority: Math.max(rule.priority, existing.priority),
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
else {
|
|
ruleMap.set(rule.id, rule);
|
|
}
|
|
}
|
|
return Array.from(ruleMap.values()).sort((a, b) => b.priority - a.priority);
|
|
}
|
|
/**
|
|
* Build the constitution from constitution-class rules
|
|
*/
|
|
buildConstitution(rules) {
|
|
// Sort by priority descending
|
|
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
|
|
// Build compact text
|
|
const lines = [
|
|
'# Constitution - Always Active Rules',
|
|
'',
|
|
];
|
|
let currentDomain = '';
|
|
for (const rule of sorted) {
|
|
const domain = rule.domains[0] || 'general';
|
|
if (domain !== currentDomain) {
|
|
currentDomain = domain;
|
|
lines.push(`## ${domain.charAt(0).toUpperCase() + domain.slice(1)}`);
|
|
}
|
|
lines.push(`- [${rule.id}] ${rule.text}`);
|
|
}
|
|
// Trim to max lines
|
|
const text = lines.slice(0, this.config.maxConstitutionLines).join('\n');
|
|
return {
|
|
rules: sorted,
|
|
text,
|
|
hash: this.hashContent(text),
|
|
};
|
|
}
|
|
/**
|
|
* Build shards from non-constitution rules
|
|
*/
|
|
buildShards(rules) {
|
|
return rules.map(rule => ({
|
|
rule,
|
|
compactText: this.buildCompactShardText(rule),
|
|
}));
|
|
}
|
|
/**
|
|
* Build compact text for a shard
|
|
*/
|
|
buildCompactShardText(rule) {
|
|
const tags = [
|
|
rule.riskClass,
|
|
...rule.domains,
|
|
...rule.intents,
|
|
...rule.toolClasses.filter(t => t !== 'all'),
|
|
].map(t => `@${t}`).join(' ');
|
|
return `[${rule.id}] ${rule.text} ${tags}`.trim();
|
|
}
|
|
/**
|
|
* Build the manifest
|
|
*/
|
|
buildManifest(allRules, rootContent, localContent) {
|
|
const sourceHashes = {
|
|
root: this.hashContent(rootContent),
|
|
};
|
|
if (localContent) {
|
|
sourceHashes.local = this.hashContent(localContent);
|
|
}
|
|
return {
|
|
rules: allRules.map(r => ({
|
|
id: r.id,
|
|
triggers: [...r.intents, ...r.domains, ...r.toolClasses],
|
|
verifier: r.verifier ?? null,
|
|
riskClass: r.riskClass,
|
|
priority: r.priority,
|
|
source: r.source,
|
|
})),
|
|
compiledAt: Date.now(),
|
|
sourceHashes,
|
|
totalRules: allRules.length,
|
|
constitutionRules: allRules.filter(r => r.isConstitution).length,
|
|
shardRules: allRules.filter(r => !r.isConstitution).length,
|
|
};
|
|
}
|
|
/**
|
|
* Hash content for change detection
|
|
*/
|
|
hashContent(content) {
|
|
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
}
|
|
}
|
|
/**
|
|
* Create a compiler instance
|
|
*/
|
|
export function createCompiler(config) {
|
|
return new GuidanceCompiler(config);
|
|
}
|
|
//# sourceMappingURL=compiler.js.map
|