458 lines
17 KiB
JavaScript
458 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* dependency-validator.js - Skill dependency validation system
|
|
* Part of LCBP3-DMS Phase 3 enhancements
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const yaml = require('js-yaml');
|
|
|
|
// Configuration
|
|
const BASE_DIR = path.resolve(__dirname, '../..');
|
|
const AGENTS_DIR = path.join(BASE_DIR, '.agents');
|
|
const SKILLS_DIR = path.join(AGENTS_DIR, 'skills');
|
|
const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows');
|
|
|
|
// Dependency validation class
|
|
class DependencyValidator {
|
|
constructor() {
|
|
this.validationResults = {
|
|
timestamp: new Date().toISOString(),
|
|
dependency_graph: {},
|
|
circular_dependencies: [],
|
|
missing_dependencies: [],
|
|
orphaned_skills: [],
|
|
dependency_chains: {},
|
|
validation_summary: {
|
|
total_skills: 0,
|
|
skills_with_dependencies: 0,
|
|
circular_dependencies_found: 0,
|
|
missing_dependencies_found: 0,
|
|
orphaned_skills_found: 0,
|
|
max_dependency_depth: 0,
|
|
validation_status: 'unknown'
|
|
}
|
|
};
|
|
}
|
|
|
|
log(message, level = 'info') {
|
|
const colors = {
|
|
info: '\x1b[36m', // Cyan
|
|
pass: '\x1b[32m', // Green
|
|
fail: '\x1b[31m', // Red
|
|
warn: '\x1b[33m', // Yellow
|
|
critical: '\x1b[35m', // Magenta
|
|
reset: '\x1b[0m'
|
|
};
|
|
|
|
const color = colors[level] || colors.info;
|
|
console.log(`${color}[${level.toUpperCase()}] ${message}${colors.reset}`);
|
|
}
|
|
|
|
extractSkillDependencies(skillPath, skillName) {
|
|
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
|
|
if (!fs.existsSync(skillMdPath)) {
|
|
this.log(`No SKILL.md found for ${skillName}`, 'warn');
|
|
return { dependencies: [], handoffs: [], error: 'SKILL.md not found' };
|
|
}
|
|
|
|
try {
|
|
const content = fs.readFileSync(skillMdPath, 'utf8');
|
|
|
|
// Extract dependencies from front matter
|
|
let dependencies = [];
|
|
let handoffs = [];
|
|
|
|
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (frontMatterMatch) {
|
|
try {
|
|
const frontMatter = yaml.load(frontMatterMatch[1]);
|
|
|
|
// Handle depends-on field
|
|
if (frontMatter['depends-on']) {
|
|
if (Array.isArray(frontMatter['depends-on'])) {
|
|
dependencies = frontMatter['depends-on'];
|
|
} else {
|
|
dependencies = [frontMatter['depends-on']];
|
|
}
|
|
}
|
|
|
|
// Handle handoffs field
|
|
if (frontMatter.handoffs && Array.isArray(frontMatter.handoffs)) {
|
|
handoffs = frontMatter.handoffs.map(h => h.agent);
|
|
}
|
|
|
|
} catch (yamlError) {
|
|
this.log(`Invalid YAML in ${skillName} front matter: ${yamlError.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
// Also extract skill references from content
|
|
const contentSkillRefs = content.match(/@speckit-\w+/g) || [];
|
|
const contentDependencies = contentSkillRefs.map(ref => ref.replace('@', ''));
|
|
|
|
// Merge dependencies (avoid duplicates)
|
|
const allDependencies = [...new Set([...dependencies, ...contentDependencies])];
|
|
|
|
return {
|
|
dependencies: allDependencies,
|
|
handoffs: handoffs,
|
|
content_references: contentSkillRefs,
|
|
front_matter_dependencies: dependencies,
|
|
error: null
|
|
};
|
|
|
|
} catch (error) {
|
|
this.log(`Error reading ${skillName}: ${error.message}`, 'warn');
|
|
return { dependencies: [], handoffs: [], error: error.message };
|
|
}
|
|
}
|
|
|
|
buildDependencyGraph() {
|
|
this.log('Building dependency graph...', 'info');
|
|
|
|
if (!fs.existsSync(SKILLS_DIR)) {
|
|
this.log('Skills directory not found', 'fail');
|
|
return;
|
|
}
|
|
|
|
const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => {
|
|
const itemPath = path.join(SKILLS_DIR, item);
|
|
return fs.statSync(itemPath).isDirectory();
|
|
});
|
|
|
|
this.validationResults.validation_summary.total_skills = skillDirs.length;
|
|
|
|
// Extract dependencies for each skill
|
|
for (const skillDir of skillDirs) {
|
|
const skillPath = path.join(SKILLS_DIR, skillDir);
|
|
const dependencyInfo = this.extractSkillDependencies(skillPath, skillDir);
|
|
|
|
this.validationResults.dependency_graph[skillDir] = dependencyInfo;
|
|
|
|
if (dependencyInfo.dependencies.length > 0 || dependencyInfo.handoffs.length > 0) {
|
|
this.validationResults.validation_summary.skills_with_dependencies++;
|
|
}
|
|
}
|
|
|
|
this.log(`Analyzed ${skillDirs.length} skills`, 'info');
|
|
this.log(`Skills with dependencies: ${this.validationResults.validation_summary.skills_with_dependencies}`, 'info');
|
|
}
|
|
|
|
validateDependencies() {
|
|
this.log('Validating dependencies...', 'info');
|
|
|
|
const { dependency_graph } = this.validationResults;
|
|
const allSkills = Object.keys(dependency_graph);
|
|
|
|
// Check for missing dependencies
|
|
for (const [skillName, dependencyInfo] of Object.entries(dependency_graph)) {
|
|
for (const dependency of dependencyInfo.dependencies) {
|
|
if (!allSkills.includes(dependency)) {
|
|
this.validationResults.missing_dependencies.push({
|
|
skill: skillName,
|
|
missing_dependency: dependency,
|
|
dependency_type: 'depends-on'
|
|
});
|
|
this.validationResults.validation_summary.missing_dependencies_found++;
|
|
this.log(`Missing dependency: ${skillName} depends on ${dependency}`, 'fail');
|
|
}
|
|
}
|
|
|
|
for (const handoff of dependencyInfo.handoffs) {
|
|
if (!allSkills.includes(handoff)) {
|
|
this.validationResults.missing_dependencies.push({
|
|
skill: skillName,
|
|
missing_dependency: handoff,
|
|
dependency_type: 'handoff'
|
|
});
|
|
this.validationResults.validation_summary.missing_dependencies_found++;
|
|
this.log(`Missing handoff: ${skillName} hands off to ${handoff}`, 'fail');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for orphaned skills (no one depends on them)
|
|
const dependedOnSkills = new Set();
|
|
for (const dependencyInfo of Object.values(dependency_graph)) {
|
|
dependencyInfo.dependencies.forEach(dep => dependedOnSkills.add(dep));
|
|
dependencyInfo.handoffs.forEach(handoff => dependedOnSkills.add(handoff));
|
|
}
|
|
|
|
for (const skill of allSkills) {
|
|
if (!dependedOnSkills.has(skill) && skill !== 'speckit-constitution') {
|
|
// Constitution is allowed to be orphaned (it's a starting point)
|
|
this.validationResults.orphaned_skills.push(skill);
|
|
this.validationResults.validation_summary.orphaned_skills_found++;
|
|
this.log(`Orphaned skill: ${skill} (no dependencies on it)`, 'warn');
|
|
}
|
|
}
|
|
}
|
|
|
|
detectCircularDependencies() {
|
|
this.log('Detecting circular dependencies...', 'info');
|
|
|
|
const { dependency_graph } = this.validationResults;
|
|
const visited = new Set();
|
|
const recursionStack = new Set();
|
|
const circularDeps = [];
|
|
|
|
function dfs(skillName, path = []) {
|
|
if (recursionStack.has(skillName)) {
|
|
// Found circular dependency
|
|
const cycleStart = path.indexOf(skillName);
|
|
const cycle = path.slice(cycleStart).concat(skillName);
|
|
circularDeps.push(cycle);
|
|
return;
|
|
}
|
|
|
|
if (visited.has(skillName)) {
|
|
return;
|
|
}
|
|
|
|
visited.add(skillName);
|
|
recursionStack.add(skillName);
|
|
path.push(skillName);
|
|
|
|
const dependencyInfo = dependency_graph[skillName];
|
|
if (dependencyInfo) {
|
|
for (const dependency of dependencyInfo.dependencies) {
|
|
dfs(dependency, [...path]);
|
|
}
|
|
}
|
|
|
|
recursionStack.delete(skillName);
|
|
}
|
|
|
|
// Run DFS from each skill
|
|
for (const skillName of Object.keys(dependency_graph)) {
|
|
if (!visited.has(skillName)) {
|
|
dfs(skillName);
|
|
}
|
|
}
|
|
|
|
this.validationResults.circular_dependencies = circularDeps;
|
|
this.validationResults.validation_summary.circular_dependencies_found = circularDeps.length;
|
|
|
|
if (circularDeps.length > 0) {
|
|
this.log(`Found ${circularDeps.length} circular dependencies:`, 'critical');
|
|
circularDeps.forEach((cycle, index) => {
|
|
this.log(` ${index + 1}. ${cycle.join(' -> ')}`, 'critical');
|
|
});
|
|
} else {
|
|
this.log('No circular dependencies found', 'pass');
|
|
}
|
|
}
|
|
|
|
calculateDependencyChains() {
|
|
this.log('Calculating dependency chains...', 'info');
|
|
|
|
const { dependency_graph } = this.validationResults;
|
|
const chains = {};
|
|
|
|
function calculateDepth(skillName, visited = new Set()) {
|
|
if (visited.has(skillName)) {
|
|
return 0; // Circular dependency protection
|
|
}
|
|
|
|
visited.add(skillName);
|
|
|
|
const dependencyInfo = dependency_graph[skillName];
|
|
if (!dependencyInfo || dependencyInfo.dependencies.length === 0) {
|
|
return 1;
|
|
}
|
|
|
|
let maxDepth = 0;
|
|
for (const dependency of dependencyInfo.dependencies) {
|
|
const depth = calculateDepth(dependency, new Set(visited));
|
|
maxDepth = Math.max(maxDepth, depth);
|
|
}
|
|
|
|
return maxDepth + 1;
|
|
}
|
|
|
|
function getDependencyChain(skillName) {
|
|
const dependencyInfo = dependency_graph[skillName];
|
|
if (!dependencyInfo || dependencyInfo.dependencies.length === 0) {
|
|
return [skillName];
|
|
}
|
|
|
|
const chains = [];
|
|
for (const dependency of dependencyInfo.dependencies) {
|
|
const depChain = getDependencyChain(dependency);
|
|
chains.push(depChain.concat(skillName));
|
|
}
|
|
|
|
// Return the longest chain
|
|
return chains.reduce((longest, current) =>
|
|
current.length > longest.length ? current : longest, [skillName]
|
|
);
|
|
}
|
|
|
|
for (const skillName of Object.keys(dependency_graph)) {
|
|
const depth = calculateDepth(skillName);
|
|
const chain = getDependencyChain(skillName);
|
|
|
|
chains[skillName] = {
|
|
depth: depth,
|
|
chain: chain,
|
|
chain_length: chain.length
|
|
};
|
|
}
|
|
|
|
this.validationResults.dependency_chains = chains;
|
|
|
|
const maxDepth = Math.max(...Object.values(chains).map(c => c.depth));
|
|
this.validationResults.validation_summary.max_dependency_depth = maxDepth;
|
|
|
|
this.log(`Maximum dependency depth: ${maxDepth}`, 'info');
|
|
}
|
|
|
|
validateWorkflowDependencies() {
|
|
this.log('Validating workflow dependencies...', 'info');
|
|
|
|
if (!fs.existsSync(WORKFLOWS_DIR)) {
|
|
this.log('Workflows directory not found', 'warn');
|
|
return;
|
|
}
|
|
|
|
const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter(file => file.endsWith('.md'));
|
|
const allSkills = Object.keys(this.validationResults.dependency_graph);
|
|
|
|
for (const workflowFile of workflowFiles) {
|
|
const workflowPath = path.join(WORKFLOWS_DIR, workflowFile);
|
|
|
|
try {
|
|
const content = fs.readFileSync(workflowPath, 'utf8');
|
|
const skillReferences = content.match(/@speckit-\w+/g) || [];
|
|
|
|
for (const skillRef of skillReferences) {
|
|
const skillName = skillRef.replace('@', '');
|
|
|
|
if (!allSkills.includes(skillName)) {
|
|
this.validationResults.missing_dependencies.push({
|
|
workflow: workflowFile,
|
|
missing_dependency: skillName,
|
|
dependency_type: 'workflow-reference'
|
|
});
|
|
this.validationResults.validation_summary.missing_dependencies_found++;
|
|
this.log(`Workflow ${workflowFile} references missing skill: ${skillRef}`, 'fail');
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
this.log(`Error reading workflow ${workflowFile}: ${error.message}`, 'warn');
|
|
}
|
|
}
|
|
}
|
|
|
|
generateDependencyReport() {
|
|
this.log('Generating dependency report...', 'info');
|
|
|
|
// Determine overall validation status
|
|
const summary = this.validationResults.validation_summary;
|
|
|
|
if (summary.circular_dependencies_found > 0) {
|
|
summary.validation_status = 'critical';
|
|
} else if (summary.missing_dependencies_found > 0) {
|
|
summary.validation_status = 'failed';
|
|
} else if (summary.orphaned_skills_found > 0) {
|
|
summary.validation_status = 'warning';
|
|
} else {
|
|
summary.validation_status = 'passed';
|
|
}
|
|
|
|
// Save report
|
|
const reportPath = path.join(AGENTS_DIR, 'reports', 'dependency-validation.json');
|
|
const reportsDir = path.dirname(reportPath);
|
|
|
|
if (!fs.existsSync(reportsDir)) {
|
|
fs.mkdirSync(reportsDir, { recursive: true });
|
|
}
|
|
|
|
fs.writeFileSync(reportPath, JSON.stringify(this.validationResults, null, 2));
|
|
this.log(`Dependency validation report saved to: ${reportPath}`, 'info');
|
|
}
|
|
|
|
printSummary() {
|
|
const summary = this.validationResults.validation_summary;
|
|
|
|
this.log('=== Dependency Validation Summary ===', 'info');
|
|
this.log(`Total skills: ${summary.total_skills}`, 'info');
|
|
this.log(`Skills with dependencies: ${summary.skills_with_dependencies}`, 'info');
|
|
this.log(`Circular dependencies: ${summary.circular_dependencies_found}`, summary.circular_dependencies_found > 0 ? 'critical' : 'pass');
|
|
this.log(`Missing dependencies: ${summary.missing_dependencies_found}`, summary.missing_dependencies_found > 0 ? 'fail' : 'pass');
|
|
this.log(`Orphaned skills: ${summary.orphaned_skills_found}`, summary.orphaned_skills_found > 0 ? 'warn' : 'info');
|
|
this.log(`Max dependency depth: ${summary.max_dependency_depth}`, 'info');
|
|
this.log(`Validation status: ${summary.validation_status.toUpperCase()}`,
|
|
summary.validation_status === 'passed' ? 'pass' :
|
|
summary.validation_status === 'warning' ? 'warn' : 'fail');
|
|
|
|
// Show longest dependency chains
|
|
const chains = this.validationResults.dependency_chains;
|
|
const sortedChains = Object.entries(chains)
|
|
.sort(([,a], [,b]) => b.depth - a.depth)
|
|
.slice(0, 3);
|
|
|
|
if (sortedChains.length > 0) {
|
|
this.log('Top 3 longest dependency chains:', 'info');
|
|
sortedChains.forEach(([skillName, chainInfo], index) => {
|
|
this.log(` ${index + 1}. ${chainInfo.chain.join(' -> ')} (depth: ${chainInfo.depth})`, 'info');
|
|
});
|
|
}
|
|
}
|
|
|
|
async runDependencyValidation() {
|
|
this.log('Starting dependency validation...', 'info');
|
|
this.log(`Base directory: ${BASE_DIR}`, 'info');
|
|
|
|
// Build dependency graph
|
|
this.buildDependencyGraph();
|
|
|
|
// Validate dependencies
|
|
this.validateDependencies();
|
|
|
|
// Detect circular dependencies
|
|
this.detectCircularDependencies();
|
|
|
|
// Calculate dependency chains
|
|
this.calculateDependencyChains();
|
|
|
|
// Validate workflow dependencies
|
|
this.validateWorkflowDependencies();
|
|
|
|
// Generate report
|
|
this.generateDependencyReport();
|
|
|
|
// Print summary
|
|
this.printSummary();
|
|
|
|
return this.validationResults;
|
|
}
|
|
}
|
|
|
|
// CLI interface
|
|
async function main() {
|
|
const validator = new DependencyValidator();
|
|
|
|
try {
|
|
const results = await validator.runDependencyValidation();
|
|
const status = results.validation_summary.validation_status;
|
|
process.exit(status === 'passed' || status === 'warning' ? 0 : 1);
|
|
} catch (error) {
|
|
console.error('Dependency validation failed:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Export for use in other modules
|
|
module.exports = { DependencyValidator };
|
|
|
|
// Run if called directly
|
|
if (require.main === module) {
|
|
main();
|
|
}
|