541 lines
16 KiB
JavaScript
541 lines
16 KiB
JavaScript
/**
|
|
* Rule Transformer Module
|
|
* Handles conversion of Cursor rules to profile rules
|
|
*
|
|
* This module procedurally generates .{profile}/rules files from assets/rules files,
|
|
* eliminating the need to maintain both sets of files manually.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { log } from '../../scripts/modules/utils.js';
|
|
|
|
// Import asset resolver
|
|
import { assetExists, readAsset, getAssetsDir } from './asset-resolver.js';
|
|
|
|
// Import the shared MCP configuration helper
|
|
import {
|
|
setupMCPConfiguration,
|
|
removeTaskMasterMCPConfiguration
|
|
} from './create-mcp-config.js';
|
|
|
|
// Import profile constants (single source of truth)
|
|
import { RULE_PROFILES } from '../constants/profiles.js';
|
|
|
|
// --- Profile Imports ---
|
|
import * as profilesModule from '../profiles/index.js';
|
|
|
|
export function isValidProfile(profile) {
|
|
return RULE_PROFILES.includes(profile);
|
|
}
|
|
|
|
/**
|
|
* Get rule profile by name
|
|
* @param {string} name - Profile name
|
|
* @returns {Object|null} Profile object or null if not found
|
|
*/
|
|
export function getRulesProfile(name) {
|
|
if (!isValidProfile(name)) {
|
|
return null;
|
|
}
|
|
|
|
// Get the profile from the imported profiles module
|
|
const profileKey = `${name}Profile`;
|
|
const profile = profilesModule[profileKey];
|
|
|
|
if (!profile) {
|
|
throw new Error(
|
|
`Profile not found: static import missing for '${name}'. Valid profiles: ${RULE_PROFILES.join(', ')}`
|
|
);
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
/**
|
|
* Replace basic Cursor terms with profile equivalents
|
|
*/
|
|
function replaceBasicTerms(content, conversionConfig) {
|
|
let result = content;
|
|
|
|
// Apply profile term replacements
|
|
conversionConfig.profileTerms.forEach((pattern) => {
|
|
if (typeof pattern.to === 'function') {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
} else {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
}
|
|
});
|
|
|
|
// Apply file extension replacements
|
|
conversionConfig.fileExtensions.forEach((pattern) => {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Replace Cursor tool references with profile tool equivalents
|
|
*/
|
|
function replaceToolReferences(content, conversionConfig) {
|
|
let result = content;
|
|
|
|
// Basic pattern for direct tool name replacements
|
|
const toolNames = conversionConfig.toolNames;
|
|
const toolReferencePattern = new RegExp(
|
|
`\\b(${Object.keys(toolNames).join('|')})\\b`,
|
|
'g'
|
|
);
|
|
|
|
// Apply direct tool name replacements
|
|
result = result.replace(toolReferencePattern, (match, toolName) => {
|
|
return toolNames[toolName] || toolName;
|
|
});
|
|
|
|
// Apply contextual tool replacements
|
|
conversionConfig.toolContexts.forEach((pattern) => {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
});
|
|
|
|
// Apply tool group replacements
|
|
conversionConfig.toolGroups.forEach((pattern) => {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Update documentation URLs to point to profile documentation
|
|
*/
|
|
function updateDocReferences(content, conversionConfig) {
|
|
let result = content;
|
|
|
|
// Apply documentation URL replacements
|
|
conversionConfig.docUrls.forEach((pattern) => {
|
|
if (typeof pattern.to === 'function') {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
} else {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Update file references in markdown links
|
|
*/
|
|
function updateFileReferences(content, conversionConfig) {
|
|
const { pathPattern, replacement } = conversionConfig.fileReferences;
|
|
return content.replace(pathPattern, replacement);
|
|
}
|
|
|
|
/**
|
|
* Transform rule content to profile-specific rules
|
|
* @param {string} content - The content to transform
|
|
* @param {Object} conversionConfig - The conversion configuration
|
|
* @param {Object} globalReplacements - Global text replacements
|
|
* @returns {string} - The transformed content
|
|
*/
|
|
function transformRuleContent(content, conversionConfig, globalReplacements) {
|
|
let result = content;
|
|
|
|
// Apply all transformations in appropriate order
|
|
result = updateFileReferences(result, conversionConfig);
|
|
result = replaceBasicTerms(result, conversionConfig);
|
|
result = replaceToolReferences(result, conversionConfig);
|
|
result = updateDocReferences(result, conversionConfig);
|
|
|
|
// Apply any global/catch-all replacements from the profile
|
|
// Super aggressive failsafe pass to catch any variations we might have missed
|
|
// This ensures critical transformations are applied even in contexts we didn't anticipate
|
|
globalReplacements.forEach((pattern) => {
|
|
if (typeof pattern.to === 'function') {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
} else {
|
|
result = result.replace(pattern.from, pattern.to);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Convert a Cursor rule file to a profile-specific rule file
|
|
* @param {string} sourcePath - Path to the source .mdc file
|
|
* @param {string} targetPath - Path to the target file
|
|
* @param {Object} profile - The profile configuration
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
export function convertRuleToProfileRule(sourcePath, targetPath, profile) {
|
|
const { conversionConfig, globalReplacements } = profile;
|
|
try {
|
|
// Read source content
|
|
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
|
|
// Transform content
|
|
const transformedContent = transformRuleContent(
|
|
content,
|
|
conversionConfig,
|
|
globalReplacements
|
|
);
|
|
|
|
// Ensure target directory exists
|
|
const targetDir = path.dirname(targetPath);
|
|
if (!fs.existsSync(targetDir)) {
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
}
|
|
|
|
// Write transformed content
|
|
fs.writeFileSync(targetPath, transformedContent);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error converting rule file: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert all Cursor rules to profile rules for a specific profile
|
|
*/
|
|
export function convertAllRulesToProfileRules(projectRoot, profile) {
|
|
const targetDir = path.join(projectRoot, profile.rulesDir);
|
|
|
|
let success = 0;
|
|
let failed = 0;
|
|
|
|
// 1. Call onAddRulesProfile first (for pre-processing like copying assets)
|
|
if (typeof profile.onAddRulesProfile === 'function') {
|
|
try {
|
|
const assetsDir = getAssetsDir();
|
|
profile.onAddRulesProfile(targetDir, assetsDir);
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Called onAddRulesProfile for ${profile.profileName}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] onAddRulesProfile failed for ${profile.profileName}: ${error.message}`
|
|
);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
// 2. Handle fileMap-based rule conversion (if any)
|
|
const sourceFiles = Object.keys(profile.fileMap);
|
|
if (sourceFiles.length > 0) {
|
|
// Only create rules directory if we have files to copy
|
|
if (!fs.existsSync(targetDir)) {
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
}
|
|
|
|
for (const sourceFile of sourceFiles) {
|
|
// Determine if this is an asset file (not a rule file)
|
|
const isAssetFile = !sourceFile.startsWith('rules/');
|
|
|
|
try {
|
|
// Check if source file exists using asset resolver
|
|
if (!assetExists(sourceFile)) {
|
|
log(
|
|
'warn',
|
|
`[Rule Transformer] Source file not found: ${sourceFile}, skipping`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const targetFilename = profile.fileMap[sourceFile];
|
|
const targetPath = path.join(targetDir, targetFilename);
|
|
|
|
// Ensure target subdirectory exists (for rules like taskmaster/dev_workflow.md)
|
|
const targetFileDir = path.dirname(targetPath);
|
|
if (!fs.existsSync(targetFileDir)) {
|
|
fs.mkdirSync(targetFileDir, { recursive: true });
|
|
}
|
|
|
|
// Read source content using asset resolver
|
|
let content = readAsset(sourceFile, 'utf8');
|
|
|
|
// Apply transformations (only if this is a rule file, not an asset file)
|
|
if (!isAssetFile) {
|
|
content = transformRuleContent(
|
|
content,
|
|
profile.conversionConfig,
|
|
profile.globalReplacements
|
|
);
|
|
}
|
|
|
|
// Write to target
|
|
fs.writeFileSync(targetPath, content, 'utf8');
|
|
success++;
|
|
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] ${isAssetFile ? 'Copied' : 'Converted'} ${sourceFile} -> ${targetFilename} for ${profile.profileName}`
|
|
);
|
|
} catch (error) {
|
|
failed++;
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] Failed to ${isAssetFile ? 'copy' : 'convert'} ${sourceFile} for ${profile.profileName}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Setup MCP configuration (if enabled)
|
|
if (profile.mcpConfig !== false) {
|
|
try {
|
|
setupMCPConfiguration(projectRoot, profile.mcpConfigPath);
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Setup MCP configuration for ${profile.profileName}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] MCP setup failed for ${profile.profileName}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. Call post-conversion hook (for finalization)
|
|
if (typeof profile.onPostConvertRulesProfile === 'function') {
|
|
try {
|
|
const assetsDir = getAssetsDir();
|
|
profile.onPostConvertRulesProfile(targetDir, assetsDir);
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Called onPostConvertRulesProfile for ${profile.profileName}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] onPostConvertRulesProfile failed for ${profile.profileName}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Ensure we return at least 1 success for profiles that only use lifecycle functions
|
|
return { success: Math.max(success, 1), failed };
|
|
}
|
|
|
|
/**
|
|
* Remove only Task Master specific files from a profile, leaving other existing rules intact
|
|
* @param {string} projectRoot - Target project directory
|
|
* @param {Object} profile - Profile configuration
|
|
* @returns {Object} Result object
|
|
*/
|
|
export function removeProfileRules(projectRoot, profile) {
|
|
const targetDir = path.join(projectRoot, profile.rulesDir);
|
|
const profileDir = path.join(projectRoot, profile.profileDir);
|
|
|
|
const result = {
|
|
profileName: profile.profileName,
|
|
success: false,
|
|
skipped: false,
|
|
error: null,
|
|
filesRemoved: [],
|
|
mcpResult: null,
|
|
profileDirRemoved: false,
|
|
notice: null
|
|
};
|
|
|
|
try {
|
|
// 1. Call onRemoveRulesProfile first (for custom cleanup like removing assets)
|
|
if (typeof profile.onRemoveRulesProfile === 'function') {
|
|
try {
|
|
profile.onRemoveRulesProfile(targetDir);
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Called onRemoveRulesProfile for ${profile.profileName}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] onRemoveRulesProfile failed for ${profile.profileName}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Remove fileMap-based files (if any)
|
|
const sourceFiles = Object.keys(profile.fileMap);
|
|
if (sourceFiles.length > 0) {
|
|
// Check if profile directory exists at all (for full profiles)
|
|
if (!fs.existsSync(profileDir)) {
|
|
result.success = true;
|
|
result.skipped = true;
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Profile directory does not exist: ${profileDir}`
|
|
);
|
|
return result;
|
|
}
|
|
|
|
let hasOtherRulesFiles = false;
|
|
|
|
if (fs.existsSync(targetDir)) {
|
|
// Get list of files we're responsible for
|
|
const taskMasterFiles = sourceFiles.map(
|
|
(sourceFile) => profile.fileMap[sourceFile]
|
|
);
|
|
|
|
// Get all files in the rules directory
|
|
// For root-level directories, we need to be careful to avoid circular symlinks
|
|
let allFiles = [];
|
|
if (targetDir === projectRoot || profile.rulesDir === '.') {
|
|
// For root directory, manually read without recursion into problematic directories
|
|
const items = fs.readdirSync(targetDir);
|
|
for (const item of items) {
|
|
// Skip directories that can cause issues or are irrelevant
|
|
if (item === 'node_modules' || item === '.git' || item === 'dist') {
|
|
continue;
|
|
}
|
|
const itemPath = path.join(targetDir, item);
|
|
try {
|
|
const stats = fs.lstatSync(itemPath);
|
|
if (stats.isFile()) {
|
|
allFiles.push(item);
|
|
} else if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
// Only recurse into safe directories
|
|
const subFiles = fs.readdirSync(itemPath, { recursive: true });
|
|
subFiles.forEach((subFile) => {
|
|
allFiles.push(path.join(item, subFile.toString()));
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Silently skip files we can't access
|
|
}
|
|
}
|
|
} else {
|
|
// For non-root directories, use normal recursive read
|
|
allFiles = fs.readdirSync(targetDir, { recursive: true });
|
|
}
|
|
|
|
const allFilePaths = allFiles
|
|
.filter((file) => {
|
|
const fullPath = path.join(targetDir, file);
|
|
try {
|
|
const stats = fs.statSync(fullPath);
|
|
return stats.isFile();
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
})
|
|
.map((file) => file.toString()); // Ensure it's a string
|
|
|
|
// Remove only Task Master files
|
|
for (const taskMasterFile of taskMasterFiles) {
|
|
const filePath = path.join(targetDir, taskMasterFile);
|
|
if (fs.existsSync(filePath)) {
|
|
try {
|
|
fs.rmSync(filePath, { force: true });
|
|
result.filesRemoved.push(taskMasterFile);
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Removed Task Master file: ${taskMasterFile}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] Failed to remove ${taskMasterFile}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for other (non-Task Master) files
|
|
const remainingFiles = allFilePaths.filter(
|
|
(file) => !taskMasterFiles.includes(file)
|
|
);
|
|
|
|
hasOtherRulesFiles = remainingFiles.length > 0;
|
|
|
|
// Remove empty directories or note preserved files
|
|
if (remainingFiles.length === 0) {
|
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Removed empty rules directory: ${targetDir}`
|
|
);
|
|
} else if (hasOtherRulesFiles) {
|
|
result.notice = `Preserved ${remainingFiles.length} existing rule files in ${profile.rulesDir}`;
|
|
log('info', `[Rule Transformer] ${result.notice}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Handle MCP configuration - only remove Task Master, preserve other servers
|
|
if (profile.mcpConfig !== false) {
|
|
try {
|
|
result.mcpResult = removeTaskMasterMCPConfiguration(
|
|
projectRoot,
|
|
profile.mcpConfigPath
|
|
);
|
|
if (result.mcpResult.hasOtherServers) {
|
|
if (!result.notice) {
|
|
result.notice = 'Preserved other MCP server configurations';
|
|
} else {
|
|
result.notice += '; preserved other MCP server configurations';
|
|
}
|
|
}
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Processed MCP configuration for ${profile.profileName}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] MCP cleanup failed for ${profile.profileName}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. Check if we should remove the entire profile directory
|
|
if (fs.existsSync(profileDir)) {
|
|
const remainingContents = fs.readdirSync(profileDir);
|
|
if (remainingContents.length === 0 && profile.profileDir !== '.') {
|
|
// Only remove profile directory if it's empty and not root directory
|
|
try {
|
|
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
result.profileDirRemoved = true;
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Removed empty profile directory: ${profileDir}`
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] Failed to remove profile directory ${profileDir}: ${error.message}`
|
|
);
|
|
}
|
|
} else if (remainingContents.length > 0) {
|
|
// Profile directory has remaining files/folders, add notice
|
|
const preservedNotice = `Preserved ${remainingContents.length} existing files/folders in ${profile.profileDir}`;
|
|
if (!result.notice) {
|
|
result.notice = preservedNotice;
|
|
} else {
|
|
result.notice += `; ${preservedNotice.toLowerCase()}`;
|
|
}
|
|
log('info', `[Rule Transformer] ${preservedNotice}`);
|
|
}
|
|
}
|
|
|
|
result.success = true;
|
|
log(
|
|
'debug',
|
|
`[Rule Transformer] Successfully removed ${profile.profileName} Task Master files from ${projectRoot}`
|
|
);
|
|
} catch (error) {
|
|
result.error = error.message;
|
|
log(
|
|
'error',
|
|
`[Rule Transformer] Failed to remove ${profile.profileName} rules: ${error.message}`
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|