UNPKG

agent-rules-kit

Version:

CLI tool to bootstrap AI agent rules for multiple IDEs and frameworks. Generates optimized rules for Cursor, VS Code, Claude, and 6+ other AI coding assistants.

1,182 lines (1,023 loc) 50.4 kB
/** * Stack Service for Agent Rules Kit * Manages stack detection, versions and directory structures */ import fs from 'fs-extra'; import path from 'path'; import { BaseService } from './base-service.js'; /** * Service for handling stack operations */ export class StackService extends BaseService { constructor(options = {}) { super(options); this.configService = options.configService; this.fileService = options.fileService; this.templatesDir = options.templatesDir; // Initialize stack services map this.stackServices = {}; this.initializeStackServices(options); } /** * Initialize stack-specific services * @param {Object} options - Options for service initialization */ async initializeStackServices(options) { const serviceMap = { laravel: () => import('./stack/laravel-service.js').then(m => m.LaravelService), nextjs: () => import('./stack/nextjs-service.js').then(m => m.NextjsService), react: () => import('./stack/react-service.js').then(m => m.ReactService), angular: () => import('./stack/angular-service.js').then(m => m.AngularService), astro: () => import('./stack/astro-service.js').then(m => m.AstroService) }; for (const [stack, importFn] of Object.entries(serviceMap)) { try { const ServiceClass = await importFn(); this.stackServices[stack] = new ServiceClass(options); this.debugLog(`Initialized ${stack} service`); } catch (error) { this.debugLog(`Failed to load ${stack} service: ${error.message}`); } } } /** * Get all available stacks from the kit-config and template directories * @returns {Array<string>} List of available stacks */ getAvailableStacks() { // First, check which stacks are defined in the config const kitConfig = this.configService.loadKitConfig(this.templatesDir); const configStacks = Object.keys(kitConfig) .filter(key => typeof kitConfig[key] === 'object' && !['global'].includes(key)); this.debugLog(`Stacks defined in config: ${configStacks.join(', ')}`); // Then check which stacks have template directories const stacksDir = path.join(this.templatesDir, 'stacks'); let templateStacks = []; if (fs.existsSync(stacksDir)) { templateStacks = fs.readdirSync(stacksDir) .filter(item => fs.statSync(path.join(stacksDir, item)).isDirectory()); this.debugLog(`Stacks with template directories: ${templateStacks.join(', ')}`); } // Combine and deduplicate the lists while maintaining order // We prioritize template directories and then add any missing config stacks const allStacks = [...templateStacks]; configStacks.forEach(stack => { if (!allStacks.includes(stack)) { allStacks.push(stack); } }); this.debugLog(`Combined available stacks: ${allStacks.join(', ')}`); return allStacks; } /** * Get available architectures for a specific stack * @param {string} stack - The stack to check * @returns {Array<Object>} - List of available architectures with name and value */ getAvailableArchitectures(stack) { const kitConfig = this.configService.loadKitConfig(this.templatesDir); // Check if architectures are defined in config const configArchitectures = kitConfig[stack]?.architectures ? Object.keys(kitConfig[stack].architectures) : []; this.debugLog(`Architectures defined in config for ${stack}: ${configArchitectures.join(', ')}`); // Check for architecture directories const archDir = path.join(this.templatesDir, 'stacks', stack, 'architectures'); let dirArchitectures = []; if (fs.existsSync(archDir)) { dirArchitectures = fs.readdirSync(archDir) .filter(item => fs.statSync(path.join(archDir, item)).isDirectory()); this.debugLog(`Architecture directories for ${stack}: ${dirArchitectures.join(', ')}`); } // Combine and deduplicate while preserving proper order // Prioritize architectures defined in config first const allArchitectures = [...configArchitectures]; dirArchitectures.forEach(arch => { if (!allArchitectures.includes(arch)) { allArchitectures.push(arch); } }); this.debugLog(`Combined architectures for ${stack}: ${allArchitectures.join(', ')}`); // Create formatted architecture objects with display names from config return allArchitectures.map(arch => { // Try to get name from config const configName = kitConfig[stack]?.architectures?.[arch]?.name; return { name: configName || this.formatArchitectureName(arch, stack), value: arch }; }); } /** * Format architecture name for display (fallback if not defined in config) * @param {string} name - Architecture key * @param {string} stack - Stack name for context-specific formatting * @returns {string} - Formatted name */ formatArchitectureName(name, stack) { // Default formatting: capitalize and make readable return name .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * Get available versions for a stack * @param {string} stack - The stack to check * @returns {Array<Object>} - List of available versions with name and value */ getAvailableVersions(stack) { const kitConfig = this.configService.loadKitConfig(this.templatesDir); // Get versions from stack-specific version ranges const versionRanges = kitConfig[stack]?.version_ranges || {}; // Transform version ranges into an array of version objects const versions = []; // Process each version entry individually to avoid duplicate options Object.entries(versionRanges).forEach(([versionNum, versionData]) => { // Check if we already added this exact version if (!versions.some(v => v.value === versionNum)) { versions.push({ name: versionNum, // Usar la clave como nombre principal value: versionNum // Y también como valor }); } }); this.debugLog(`Available versions for ${stack}: ${versions.map(v => v.name).join(', ')}`); return versions; } /** * Extract numeric version from a version string * @param {string} version - Version string (like "v10" or "v10-11") * @returns {number|null} - Extracted number or null if not found */ extractVersionNumber(version) { const match = version.match(/^v(\d+)/); if (match && match[1]) { return parseInt(match[1], 10); } return null; } /** * Format version name for display * @param {string} stack - The stack * @param {string} version - Version string * @returns {string} - Formatted name */ formatVersionName(stack, version) { // Ensure version is a string const versionStr = String(version); // Format based on stack and version range if (stack === 'laravel') { if (versionStr === 'v10-11') return 'Laravel 10-11'; if (versionStr === 'v8-9') return 'Laravel 8-9'; if (versionStr === 'v12') return 'Laravel 12'; if (versionStr === 'v7') return 'Laravel 7'; } else if (stack === 'nextjs') { if (versionStr === 'v14') return 'Next.js 14'; if (versionStr === 'v13') return 'Next.js 13'; if (versionStr === 'v12') return 'Next.js 12'; } else if (stack === 'react') { if (versionStr === 'v17') return 'React 17'; if (versionStr === 'v18') return 'React 18'; } else if (stack === 'vue') { if (versionStr === 'v2') return 'Vue 2'; if (versionStr === 'v3') return 'Vue 3'; } else if (stack === 'nuxt') { if (versionStr === 'v2') return 'Nuxt 2'; if (versionStr === 'v3') return 'Nuxt 3'; } else if (stack === 'angular') { if (versionStr === 'v14-15') return 'Angular 14-15'; if (versionStr === 'v16-17') return 'Angular 16-17'; } // Default formatting - capitalize and make readable return versionStr .replace('v', `${stack.charAt(0).toUpperCase() + stack.slice(1)} `) .replace('-', ' to '); } /** * Map a specific version to a version range * @param {string} stack - The stack * @param {string} version - The specific version * @returns {string} - The mapped version range or null */ mapVersionToRange(stack, version) { if (!version) return null; const kitConfig = this.configService.loadKitConfig(this.templatesDir); const versionRanges = kitConfig[stack]?.version_ranges || {}; // Direct mapping - version is a key in version_ranges if (versionRanges[version]) { return versionRanges[version].range_name; } this.debugLog(`No mapping found for ${stack} version ${version}`); return null; } /** * Get formatted version name for a specific version range * @param {string} stack - The stack * @param {string} versionRange - The version range (e.g. v10-11) * @returns {string} - Formatted version name */ getFormattedVersionName(stack, versionRange) { if (!versionRange) return null; // Try to find the formatted name in the configuration const kitConfig = this.configService.loadKitConfig(this.templatesDir); const versionRanges = kitConfig[stack]?.version_ranges || {}; // Check directly if this version exists in the config if (versionRanges[versionRange]) { return versionRanges[versionRange].name; } // If we're looking for a range_name, find the entry that matches for (const [key, value] of Object.entries(versionRanges)) { if (value.range_name === versionRange) { return value.name; } } // Default formatting if not found in config return `${stack.charAt(0).toUpperCase() + stack.slice(1)} ${versionRange}`; } /** * Generic function to detect version from package.json * @param {string} projectPath - Path to the project root * @param {string} packageName - Name of the package to look for * @param {Array<string>} possibleLocations - Possible locations (dependencies, devDependencies, etc.) * @returns {string|null} - Detected version or null if not found */ detectPackageVersion(projectPath, packageName, possibleLocations = ['dependencies', 'devDependencies']) { try { const packagePath = path.join(projectPath, 'package.json'); this.debugLog(`Looking for package.json at: ${packagePath}`); if (!fs.existsSync(packagePath)) { this.debugLog('package.json not found'); return null; } const packageContent = fs.readFileSync(packagePath, 'utf8'); const pkg = JSON.parse(packageContent); this.debugLog(`Found package.json with content length: ${packageContent.length}`); // Check all possible locations for the package for (const location of possibleLocations) { if (pkg[location] && pkg[location][packageName]) { const versionStr = pkg[location][packageName]; this.debugLog(`Found ${packageName} version in ${location}: ${versionStr}`); // Extract major version number const match = versionStr.match(/\d+/); if (match) { const version = parseInt(match[0], 10); this.debugLog(`Detected ${packageName} version: ${version}`); return version.toString(); } } } this.debugLog(`${packageName} not found in package.json`); return null; } catch (error) { this.debugLog(`Error detecting ${packageName} version: ${error.message}`); return null; } } /** * Get the specific service for a stack * @param {string} stack - Stack name * @returns {Object|null} - Stack service instance or null if not found */ getStackService(stack) { const stackKey = stack?.toLowerCase(); if (this.stackServices[stackKey]) { return this.stackServices[stackKey]; } // If service not found, return null to fallback to generic detection this.debugLog(`No specific service found for ${stack}, using generic detection`); return null; } /** * Detect stack version using service-specific method or generic fallback * @param {string} stack - The stack to detect version for * @param {string} projectPath - Path to the project root * @returns {string|null} - Detected version or null if not detected */ detectStackVersion(stack, projectPath) { this.debugLog(`Attempting to detect ${stack} version in ${projectPath}`); // Resolve absolute path for project const resolvedPath = path.resolve(projectPath); this.debugLog(`Resolved project path: ${resolvedPath}`); // Try to get a specific service for this stack const stackService = this.getStackService(stack); const detectMethodName = `detect${stack.charAt(0).toUpperCase() + stack.slice(1).toLowerCase()}Version`; // If stack service has a specific detect method, use it if (stackService && typeof stackService[detectMethodName] === 'function') { this.debugLog(`Using specific ${detectMethodName} method`); return stackService[detectMethodName](resolvedPath); } // Fall back to generic detection based on common patterns this.debugLog(`Using generic detection for ${stack}`); return this.detectStackVersionGeneric(stack, resolvedPath); } /** * Generic version detection fallback * @param {string} stack - Stack name * @param {string} projectPath - Project path * @returns {string|null} - Detected version or null */ detectStackVersionGeneric(stack, projectPath) { // Define common package patterns for different stacks const stackPatterns = { laravel: { files: ['composer.json'], packages: ['laravel/framework'], locations: ['require'] }, nextjs: { files: ['package.json'], packages: ['next'], locations: ['dependencies', 'devDependencies'] }, react: { files: ['package.json'], packages: ['react'], locations: ['dependencies', 'devDependencies'] }, angular: { files: ['package.json'], packages: ['@angular/core'], locations: ['dependencies', 'devDependencies'] }, vue: { files: ['package.json'], packages: ['vue'], locations: ['dependencies', 'devDependencies'] }, astro: { files: ['package.json'], packages: ['astro'], locations: ['dependencies', 'devDependencies'] }, nuxt: { files: ['package.json'], packages: ['nuxt'], locations: ['dependencies', 'devDependencies'] }, svelte: { files: ['package.json'], packages: ['svelte'], locations: ['dependencies', 'devDependencies'] }, sveltekit: { files: ['package.json'], packages: ['@sveltejs/kit'], locations: ['dependencies', 'devDependencies'] } }; const pattern = stackPatterns[stack?.toLowerCase()]; if (!pattern) { this.debugLog(`No generic pattern available for ${stack}`); return null; } // For composer.json files (PHP projects) if (pattern.files.includes('composer.json')) { return this.detectComposerVersion(projectPath, pattern.packages[0]); } // For package.json files (Node.js projects) if (pattern.files.includes('package.json')) { return this.detectPackageVersion(projectPath, pattern.packages[0], pattern.locations); } return null; } /** * Detect version from composer.json * @param {string} projectPath - Project path * @param {string} packageName - Package name to look for * @returns {string|null} - Detected version or null */ detectComposerVersion(projectPath, packageName) { try { const composerPath = path.join(projectPath, 'composer.json'); this.debugLog(`Looking for composer.json at: ${composerPath}`); if (!fs.existsSync(composerPath)) { this.debugLog('composer.json not found'); return null; } const composerContent = fs.readFileSync(composerPath, 'utf8'); const composer = JSON.parse(composerContent); this.debugLog(`Found composer.json with content length: ${composerContent.length}`); if (!composer.require || !composer.require[packageName]) { this.debugLog(`${packageName} not found in composer.json require section`); return null; } const versionStr = composer.require[packageName]; this.debugLog(`Found ${packageName} version: ${versionStr}`); // Extract major version number const match = versionStr.match(/\d+/); if (match) { const version = parseInt(match[0], 10); this.debugLog(`Detected ${packageName} version: ${version}`); return version.toString(); } } catch (error) { this.debugLog(`Error detecting ${packageName} version: ${error.message}`); } return null; } /** * Format the rules output directory path * @param {string} basePath - Base path for the project * @returns {string} - The formatted path to the rules directory */ formatRulesPath(basePath) { // Always create rules in .cursor/rules/rules-kit relative to basePath const normalizedBasePath = basePath === '.' ? '' : basePath; const rulesPath = path.join(normalizedBasePath, '.cursor', 'rules', 'rules-kit'); this.debugLog(`Formatted rules path: ${rulesPath} from base: ${basePath}`); return rulesPath; } /** * Creates a backup of the rules directory * @param {string} rulesDir - Rules directory to backup * @returns {string|null} - Path to backup directory or null if failed */ createBackup(rulesDir) { try { // Check if directory exists first if (!fs.existsSync(rulesDir)) { return null; } // Generate backup directory name with date const date = new Date(); const timestamp = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}_${String(date.getHours()).padStart(2, '0')}${String(date.getMinutes()).padStart(2, '0')}`; const backupDir = `${rulesDir}-backup-${timestamp}`; // Copy directory fs.copySync(rulesDir, backupDir); this.debugLog(`Created backup at ${backupDir}`); return backupDir; } catch (error) { this.debugLog(`Failed to create backup: ${error.message}`); return null; } } /** * Creates a backup of the rules directory asynchronously * @param {string} rulesDir - Rules directory to backup * @returns {Promise<string|null>} - Path to backup directory or null if failed */ async createBackupAsync(rulesDir) { try { // Check if directory exists first if (!await fs.pathExists(rulesDir)) { return null; } // Generate backup directory name with date const date = new Date(); const timestamp = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}_${String(date.getHours()).padStart(2, '0')}${String(date.getMinutes()).padStart(2, '0')}`; const backupDir = `${rulesDir}-backup-${timestamp}`; // Copy directory asynchronously await fs.copy(rulesDir, backupDir); this.debugLog(`Created backup at ${backupDir}`); return backupDir; } catch (error) { this.debugLog(`Failed to create backup: ${error.message}`); return null; } } /** * Count the total number of rule files to be generated * @param {Object} meta - Metadata for counting rules * @returns {number} - Total number of files to be generated */ countTotalRules(meta) { let totalFiles = 0; const { stack, architecture, includeGlobalRules } = meta; try { // Count base rules const baseDir = path.join(this.templatesDir, 'stacks', stack, 'base'); if (fs.existsSync(baseDir)) { const baseFiles = fs.readdirSync(baseDir).filter(f => f.endsWith('.md')); totalFiles += baseFiles.length; this.debugLog(`Base rules count: ${baseFiles.length}`); } // Count architecture rules if specified if (architecture) { const archDir = path.join(this.templatesDir, 'stacks', stack, 'architectures', architecture); if (fs.existsSync(archDir)) { const archFiles = fs.readdirSync(archDir).filter(f => f.endsWith('.md')); totalFiles += archFiles.length; this.debugLog(`Architecture rules count: ${archFiles.length}`); } } // Count version-specific rules if applicable const versionRange = meta.versionRange; if (versionRange) { const versionDir = path.join(this.templatesDir, 'stacks', stack, versionRange); if (fs.existsSync(versionDir)) { const versionFiles = fs.readdirSync(versionDir).filter(f => f.endsWith('.md')); totalFiles += versionFiles.length; this.debugLog(`Version rules count: ${versionFiles.length}`); } } // Count global rules if included if (includeGlobalRules) { const globalDir = path.join(this.templatesDir, 'global'); if (fs.existsSync(globalDir)) { const globalFiles = fs.readdirSync(globalDir).filter(f => f.endsWith('.md')); totalFiles += globalFiles.length; this.debugLog(`Global rules count: ${globalFiles.length}`); } } // Add state management rules for React if specified if (stack === 'react' && meta.stateManagement) { const stateDir = path.join(this.templatesDir, 'stacks', stack, 'state-management', meta.stateManagement); if (fs.existsSync(stateDir)) { const stateFiles = fs.readdirSync(stateDir).filter(f => f.endsWith('.md')); totalFiles += stateFiles.length; this.debugLog(`State management rules count: ${stateFiles.length}`); } } // Add testing rules for React/Angular if (['react', 'angular'].includes(stack)) { const testingDir = path.join(this.templatesDir, 'stacks', stack, 'testing'); if (fs.existsSync(testingDir)) { const testingFiles = fs.readdirSync(testingDir).filter(f => f.endsWith('.md')); totalFiles += testingFiles.length; this.debugLog(`Testing rules count: ${testingFiles.length}`); } } // Count Angular signals rules if included if (stack === 'angular' && meta.includeSignals) { // First check for version-specific signals let signalsDir = null; if (meta.versionRange) { const versionSignalsDir = path.join(this.templatesDir, 'stacks', stack, meta.versionRange); if (fs.existsSync(versionSignalsDir)) { signalsDir = versionSignalsDir; this.debugLog(`Using version-specific signals from: ${versionSignalsDir}`); } } // If no version-specific signals found, try the common signals directory if (!signalsDir) { const commonSignalsDir = path.join(this.templatesDir, 'stacks', stack, 'signals'); if (fs.existsSync(commonSignalsDir)) { signalsDir = commonSignalsDir; this.debugLog(`Using common signals from: ${commonSignalsDir}`); } } if (signalsDir) { const signalsFiles = fs.readdirSync(signalsDir).filter(f => f.endsWith('.md')); totalFiles += signalsFiles.length; this.debugLog(`Angular signals rules count: ${signalsFiles.length}`); } } this.debugLog(`Total rule files to generate: ${totalFiles}`); return totalFiles; } catch (error) { this.debugLog(`Error counting rules: ${error.message}`); return 10; // Default fallback value } } /** * Count the total number of rule files to be generated asynchronously * @param {Object} meta - Metadata for counting rules * @returns {Promise<number>} - Total number of files to be generated */ async countTotalRulesAsync(meta) { let totalFiles = 0; const { stack, architecture, includeGlobalRules } = meta; try { // Count base rules const baseDir = path.join(this.templatesDir, 'stacks', stack, 'base'); if (await this.pathExistsAsync(baseDir)) { const baseFiles = await fs.promises.readdir(baseDir); totalFiles += baseFiles.length; this.debugLog(`Base rules count: ${baseFiles.length}`); } // Count architecture rules if specified if (architecture) { const archDir = path.join(this.templatesDir, 'stacks', stack, 'architectures', architecture); if (await this.pathExistsAsync(archDir)) { const archFiles = await fs.promises.readdir(archDir); totalFiles += archFiles.length; this.debugLog(`Architecture rules count: ${archFiles.length}`); } } // Count version-specific rules if applicable const versionRange = meta.versionRange; if (versionRange) { const versionDir = path.join(this.templatesDir, 'stacks', stack, versionRange); if (await this.pathExistsAsync(versionDir)) { const versionFiles = await fs.promises.readdir(versionDir); totalFiles += versionFiles.length; this.debugLog(`Version rules count: ${versionFiles.length}`); } } // Count global rules if included if (includeGlobalRules) { const globalDir = path.join(this.templatesDir, 'global'); if (await this.pathExistsAsync(globalDir)) { const globalFiles = await fs.promises.readdir(globalDir); totalFiles += globalFiles.length; this.debugLog(`Global rules count: ${globalFiles.length}`); } } // Add state management rules for React if specified if (stack === 'react' && meta.stateManagement) { const stateDir = path.join(this.templatesDir, 'stacks', stack, 'state-management', meta.stateManagement); if (await this.pathExistsAsync(stateDir)) { const stateFiles = await fs.promises.readdir(stateDir); totalFiles += stateFiles.length; this.debugLog(`State management rules count: ${stateFiles.length}`); } } // Add testing rules for React/Angular if (['react', 'angular'].includes(stack)) { const testingDir = path.join(this.templatesDir, 'stacks', stack, 'testing'); if (await this.pathExistsAsync(testingDir)) { const testingFiles = await fs.promises.readdir(testingDir); totalFiles += testingFiles.length; this.debugLog(`Testing rules count: ${testingFiles.length}`); } } // Count Angular signals rules if included if (stack === 'angular' && meta.includeSignals) { // First check for version-specific signals let signalsDir = null; if (meta.versionRange) { const versionSignalsDir = path.join(this.templatesDir, 'stacks', stack, meta.versionRange); if (await this.pathExistsAsync(versionSignalsDir)) { signalsDir = versionSignalsDir; this.debugLog(`Using version-specific signals from: ${versionSignalsDir}`); } } // If no version-specific signals found, try the common signals directory if (!signalsDir) { const commonSignalsDir = path.join(this.templatesDir, 'stacks', stack, 'signals'); if (await this.pathExistsAsync(commonSignalsDir)) { signalsDir = commonSignalsDir; this.debugLog(`Using common signals from: ${commonSignalsDir}`); } } if (signalsDir) { const signalsFiles = await fs.promises.readdir(signalsDir); totalFiles += signalsFiles.length; this.debugLog(`Angular signals rules count: ${signalsFiles.length}`); } } this.debugLog(`Total rule files to generate: ${totalFiles}`); return totalFiles; } catch (error) { this.debugLog(`Error counting rules: ${error.message}`); return 10; // Default fallback value } } /** * Generate rules for a stack asynchronously with progress tracking * @param {string} rulesDir - Base directory for rules * @param {Object} meta - Metadata for rule generation * @param {Object} config - Configuration * @param {Function} progressCallback - Callback for progress updates * @param {boolean} includeGlobalRules - Whether to include global rules * @returns {Promise<void>} */ async generateRulesAsync(rulesDir, meta, config, progressCallback, includeGlobalRules) { const { stack } = meta; // Create the stack-specific directory const stackRulesDir = path.join(rulesDir, stack); await this.ensureDirectoryExistsAsync(stackRulesDir); // Get a reference to the file service from this instance or from configService const fileService = this.fileService || (this.configService?.fileService); if (!fileService) { throw new Error('File service is required but not available'); } // Map to track file paths by base name for merging duplicates const fileTracker = new Map(); // Process global rules if requested if (includeGlobalRules) { // Usar always en lugar de rules ya que esa es la nomenclatura en la config const globalRules = config.global?.always || []; // Si no hay reglas definidas explícitamente, copiar todos los archivos .md de la carpeta global const globalTemplatesDir = path.join(this.templatesDir, 'global'); let filesToCopy = []; if (globalRules.length > 0) { filesToCopy = globalRules; } else if (await this.pathExistsAsync(globalTemplatesDir)) { // Obtener todos los archivos .md si no hay reglas explícitas const allFiles = await fs.promises.readdir(globalTemplatesDir); filesToCopy = allFiles.filter(file => file.endsWith('.md')); } if (filesToCopy.length > 0) { const globalDir = path.join(rulesDir, 'global'); await this.ensureDirectoryExistsAsync(globalDir); // Process in batches for better memory usage const batchSize = 10; for (let i = 0; i < filesToCopy.length; i += batchSize) { const batch = filesToCopy.slice(i, i + batchSize); await Promise.all(batch.map(async (rule) => { const sourceFile = path.join(globalTemplatesDir, rule); const destFile = path.join(globalDir, rule.replace(/\.md$/, '.mdc')); if (await this.pathExistsAsync(sourceFile)) { await fileService.wrapMdToMdcAsync(sourceFile, destFile, meta, config); this.debugLog(`Copied global rule: ${rule}`); // Update progress if (typeof progressCallback === 'function') { progressCallback(); } } else { this.debugLog(`Global rule not found: ${rule}`); } })); } } } // Map to collect all file sources by their base name // This helps us identify duplicates across base, version-specific, and architecture folders const trackedFiles = new Map(); // First collect all files from different sources // Process base rules - just collect paths, don't process yet const baseDir = path.join(this.templatesDir, 'stacks', stack, 'base'); if (await this.pathExistsAsync(baseDir)) { const baseFiles = await fs.promises.readdir(baseDir); for (const file of baseFiles) { if (file.endsWith('.md')) { const baseName = file; // e.g. testing-best-practices.md if (!trackedFiles.has(baseName)) { trackedFiles.set(baseName, []); } trackedFiles.get(baseName).push({ source: 'base', path: path.join(baseDir, file) }); } } } // Process version overlay if applicable - collect paths const versionRange = meta.versionRange; if (versionRange) { const versionDir = path.join(this.templatesDir, 'stacks', stack, versionRange); if (await this.pathExistsAsync(versionDir)) { const versionFiles = await fs.promises.readdir(versionDir); for (const file of versionFiles) { if (file.endsWith('.md')) { const baseName = file; if (!trackedFiles.has(baseName)) { trackedFiles.set(baseName, []); } trackedFiles.get(baseName).push({ source: 'version', path: path.join(versionDir, file) }); } } } } // Process architecture-specific rules if applicable - collect paths const architecture = meta.architecture; if (architecture) { const archDir = path.join(this.templatesDir, 'stacks', stack, 'architectures', architecture); if (await this.pathExistsAsync(archDir)) { const archFiles = await fs.promises.readdir(archDir); for (const file of archFiles) { if (file.endsWith('.md')) { const baseName = `architecture-${architecture}-${file}`; if (!trackedFiles.has(baseName)) { trackedFiles.set(baseName, []); } trackedFiles.get(baseName).push({ source: 'architecture', path: path.join(archDir, file) }); } } } } // Now process all collected files let processedCount = 0; for (const [baseName, fileSources] of trackedFiles.entries()) { const destFile = path.join(stackRulesDir, baseName.replace(/\.md$/, '.mdc')); // If we have multiple sources for the same file, we need to merge them if (fileSources.length > 1) { this.debugLog(`Found duplicate file ${baseName} in multiple locations. Merging...`); // Sort sources to ensure base comes first, then version, then architecture fileSources.sort((a, b) => { const order = { base: 1, version: 2, architecture: 3 }; return order[a.source] - order[b.source]; }); // Get the file paths in the correct order const filePaths = fileSources.map(source => source.path); try { // Combine the files const { frontmatter, content } = await fileService.combineMdFiles(filePaths, meta); // Create the combined .mdc file with a single frontmatter section let mdcContent = '---\n'; if (frontmatter.globs) { if (Array.isArray(frontmatter.globs)) { mdcContent += `globs: [${frontmatter.globs.map(g => `'${g}'`).join(', ')}]\n`; } else { mdcContent += `globs: '${frontmatter.globs}'\n`; } } if (frontmatter.alwaysApply !== undefined) { mdcContent += `alwaysApply: ${frontmatter.alwaysApply}\n`; } if (frontmatter.description) { mdcContent += `description: '${frontmatter.description}'\n`; } mdcContent += '---\n\n'; mdcContent += content; // Write the combined file await fs.promises.writeFile(destFile, mdcContent, 'utf8'); this.debugLog(`Created combined file: ${destFile}`); } catch (error) { this.debugLog(`Error merging files for ${baseName}: ${error.message}`); // If combining fails, use the version-specific file (highest priority) await fileService.wrapMdToMdcAsync(fileSources[fileSources.length - 1].path, destFile, meta, config); } } else { // Single source file, process normally await fileService.wrapMdToMdcAsync(fileSources[0].path, destFile, meta, config); } // Update progress processedCount++; if (typeof progressCallback === 'function') { progressCallback(); } } // Process state management rules for React if (stack === 'react' && meta.stateManagement) { const stateDir = path.join(this.templatesDir, 'stacks', stack, 'state-management', meta.stateManagement); if (await fs.pathExists(stateDir)) { const stateFiles = await fs.readdir(stateDir); // Process in batches const batchSize = 10; for (let i = 0; i < stateFiles.length; i += batchSize) { const batch = stateFiles.slice(i, i + batchSize); await Promise.all(batch.map(async (file) => { if (file.endsWith('.md')) { const sourceFile = path.join(stateDir, file); const destFile = path.join(stackRulesDir, file.replace(/\.md$/, '.mdc')); await fileService.wrapMdToMdcAsync(sourceFile, destFile, meta, config); // Update progress if (typeof progressCallback === 'function') { progressCallback(); } } })); } } } // Process testing rules for React/Angular if (['react', 'angular'].includes(stack)) { const testingDir = path.join(this.templatesDir, 'stacks', stack, 'testing'); if (await fs.pathExists(testingDir)) { const testingFiles = await fs.readdir(testingDir); // Process in batches const batchSize = 10; for (let i = 0; i < testingFiles.length; i += batchSize) { const batch = testingFiles.slice(i, i + batchSize); await Promise.all(batch.map(async (file) => { if (file.endsWith('.md')) { const sourceFile = path.join(testingDir, file); const destFile = path.join(stackRulesDir, file.replace(/\.md$/, '.mdc')); await fileService.wrapMdToMdcAsync(sourceFile, destFile, meta, config); // Update progress if (typeof progressCallback === 'function') { progressCallback(); } } })); } } } // Process Angular signals rules if applicable if (stack === 'angular' && meta.includeSignals) { // First check for version-specific signals let signalsDir = null; if (meta.versionRange) { const versionSignalsDir = path.join(this.templatesDir, 'stacks', stack, meta.versionRange); if (await fs.pathExists(versionSignalsDir)) { signalsDir = versionSignalsDir; this.debugLog(`Using version-specific signals from: ${versionSignalsDir}`); } } // If no version-specific signals found, try the common signals directory if (!signalsDir) { const commonSignalsDir = path.join(this.templatesDir, 'stacks', stack, 'signals'); if (await fs.pathExists(commonSignalsDir)) { signalsDir = commonSignalsDir; this.debugLog(`Using common signals from: ${commonSignalsDir}`); } } if (signalsDir) { const signalsFiles = await fs.readdir(signalsDir); // Process in batches const batchSize = 10; for (let i = 0; i < signalsFiles.length; i += batchSize) { const batch = signalsFiles.slice(i, i + batchSize); await Promise.all(batch.map(async (file) => { if (file.endsWith('.md')) { const sourceFile = path.join(signalsDir, file); const destFile = path.join(stackRulesDir, file.replace(/\.md$/, '.mdc')); await fileService.wrapMdToMdcAsync(sourceFile, destFile, meta, config); // Update progress if (typeof progressCallback === 'function') { progressCallback(); } } })); } } } } /** * Check if a path exists asynchronously * @param {string} path - Path to check * @returns {Promise<boolean>} - True if exists, false otherwise */ async pathExistsAsync(path) { try { await fs.access(path); return true; } catch (error) { this.debugLog(`Path does not exist: ${path}`); return false; } } /** * Copy global rules to the specified directory * @param {string} rulesDir - Target rules directory * @param {object} meta - Metadata for processing * @param {object} config - Configuration object */ async copyGlobalRules(rulesDir, meta, config) { // Get a reference to the file service const fileService = this.fileService || (this.configService?.fileService); if (!fileService) { throw new Error('File service is required but not available'); } // Use always rules instead of rules as that's the naming in config const globalRules = config.global?.always || []; // If no explicit rules defined, copy all .md files from global folder const globalTemplatesDir = path.join(this.templatesDir, 'global'); let filesToCopy = []; if (globalRules.length > 0) { filesToCopy = globalRules; } else if (await this.pathExistsAsync(globalTemplatesDir)) { // Get all .md files if no explicit rules const allFiles = await fs.promises.readdir(globalTemplatesDir); filesToCopy = allFiles.filter(file => file.endsWith('.md')); } if (filesToCopy.length > 0) { const globalDir = path.join(rulesDir, 'global'); await this.ensureDirectoryExistsAsync(globalDir); // Process in batches for better memory usage const batchSize = 10; for (let i = 0; i < filesToCopy.length; i += batchSize) { const batch = filesToCopy.slice(i, i + batchSize); await Promise.all(batch.map(async (rule) => { const sourceFile = path.join(globalTemplatesDir, rule); const destFile = path.join(globalDir, rule.replace(/\.md$/, '.mdc')); if (await this.pathExistsAsync(sourceFile)) { await fileService.wrapMdToMdcAsync(sourceFile, destFile, meta, config); this.debugLog(`Copied global rule: ${rule}`); } else { this.debugLog(`Global rule not found: ${rule}`); } })); } } return filesToCopy.length; } /** * Count stack-specific rules only (excluding global rules) * @param {object} meta - Metadata containing stack, version, architecture info * @returns {Promise<number>} - Number of stack-specific rule files */ async countStackRules(meta) { const { stack, versionRange, architecture } = meta; let count = 0; // Collect unique files from all sources const trackedFiles = new Set(); // Count base rules const baseDir = path.join(this.templatesDir, 'stacks', stack, 'base'); if (await this.pathExistsAsync(baseDir)) { const baseFiles = await fs.promises.readdir(baseDir); baseFiles.filter(file => file.endsWith('.md')).forEach(file => { trackedFiles.add(file); }); } // Count version overlay rules if (versionRange) { const versionDir = path.join(this.templatesDir, 'stacks', stack, versionRange); if (await this.pathExistsAsync(versionDir)) { const versionFiles = await fs.promises.readdir(versionDir); versionFiles.filter(file => file.endsWith('.md')).forEach(file => { trackedFiles.add(file); }); } } // Count architecture-specific rules if (architecture) { const archDir = path.join(this.templatesDir, 'stacks', stack, 'architectures', architecture); if (await this.pathExistsAsync(archDir)) { const archFiles = await fs.promises.readdir(archDir); archFiles.filter(file => file.endsWith('.md')).forEach(file => { trackedFiles.add(`architecture-${architecture}-${file}`); }); } } // Count state management rules for React if (stack === 'react' && meta.stateManagement) { const stateDir = path.join(this.templatesDir, 'stacks', stack, 'st