mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-06-27 00:29:58 +00:00

This commit introduces several significant improvements: - **Enhanced Unit Testing:** Vastly improved unit tests for the module, covering core functions, edge cases, and error handling. Simplified test functions and comprehensive mocking were implemented for better isolation and reliability. Added new section to tests.mdc detailing reliable testing techniques. - **CLI Kebab-Case Flag Enforcement:** The CLI now enforces kebab-case for flags, providing helpful error messages when camelCase is used. This improves consistency and user experience. - **AI Enhancements:** - Enabled 128k token output for Claude 3.7 Sonnet by adding the header. - Added a new task to to document this change and its testing strategy. - Added unit tests to verify the Anthropic client configuration. - Added and utility functions. - **Improved Test Coverage:** Added tests for the new CLI flag validation logic.
336 lines
9.6 KiB
JavaScript
336 lines
9.6 KiB
JavaScript
/**
|
||
* utils.js
|
||
* Utility functions for the Task Master CLI
|
||
*/
|
||
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import chalk from 'chalk';
|
||
|
||
// Configuration and constants
|
||
const CONFIG = {
|
||
model: process.env.MODEL || 'claude-3-7-sonnet-20250219',
|
||
maxTokens: parseInt(process.env.MAX_TOKENS || '4000'),
|
||
temperature: parseFloat(process.env.TEMPERATURE || '0.7'),
|
||
debug: process.env.DEBUG === "true",
|
||
logLevel: process.env.LOG_LEVEL || "info",
|
||
defaultSubtasks: parseInt(process.env.DEFAULT_SUBTASKS || "3"),
|
||
defaultPriority: process.env.DEFAULT_PRIORITY || "medium",
|
||
projectName: process.env.PROJECT_NAME || "Task Master",
|
||
projectVersion: "1.5.0" // Hardcoded version - ALWAYS use this value, ignore environment variable
|
||
};
|
||
|
||
// Set up logging based on log level
|
||
const LOG_LEVELS = {
|
||
debug: 0,
|
||
info: 1,
|
||
warn: 2,
|
||
error: 3
|
||
};
|
||
|
||
/**
|
||
* Logs a message at the specified level
|
||
* @param {string} level - The log level (debug, info, warn, error)
|
||
* @param {...any} args - Arguments to log
|
||
*/
|
||
function log(level, ...args) {
|
||
const icons = {
|
||
debug: chalk.gray('🔍'),
|
||
info: chalk.blue('ℹ️'),
|
||
warn: chalk.yellow('⚠️'),
|
||
error: chalk.red('❌'),
|
||
success: chalk.green('✅')
|
||
};
|
||
|
||
if (LOG_LEVELS[level] >= LOG_LEVELS[CONFIG.logLevel]) {
|
||
const icon = icons[level] || '';
|
||
console.log(`${icon} ${args.join(' ')}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reads and parses a JSON file
|
||
* @param {string} filepath - Path to the JSON file
|
||
* @returns {Object} Parsed JSON data
|
||
*/
|
||
function readJSON(filepath) {
|
||
try {
|
||
const rawData = fs.readFileSync(filepath, 'utf8');
|
||
return JSON.parse(rawData);
|
||
} catch (error) {
|
||
log('error', `Error reading JSON file ${filepath}:`, error.message);
|
||
if (CONFIG.debug) {
|
||
console.error(error);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Writes data to a JSON file
|
||
* @param {string} filepath - Path to the JSON file
|
||
* @param {Object} data - Data to write
|
||
*/
|
||
function writeJSON(filepath, data) {
|
||
try {
|
||
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
|
||
} catch (error) {
|
||
log('error', `Error writing JSON file ${filepath}:`, error.message);
|
||
if (CONFIG.debug) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitizes a prompt string for use in a shell command
|
||
* @param {string} prompt The prompt to sanitize
|
||
* @returns {string} Sanitized prompt
|
||
*/
|
||
function sanitizePrompt(prompt) {
|
||
// Replace double quotes with escaped double quotes
|
||
return prompt.replace(/"/g, '\\"');
|
||
}
|
||
|
||
/**
|
||
* Reads and parses the complexity report if it exists
|
||
* @param {string} customPath - Optional custom path to the report
|
||
* @returns {Object|null} The parsed complexity report or null if not found
|
||
*/
|
||
function readComplexityReport(customPath = null) {
|
||
try {
|
||
const reportPath = customPath || path.join(process.cwd(), 'scripts', 'task-complexity-report.json');
|
||
if (!fs.existsSync(reportPath)) {
|
||
return null;
|
||
}
|
||
|
||
const reportData = fs.readFileSync(reportPath, 'utf8');
|
||
return JSON.parse(reportData);
|
||
} catch (error) {
|
||
log('warn', `Could not read complexity report: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Finds a task analysis in the complexity report
|
||
* @param {Object} report - The complexity report
|
||
* @param {number} taskId - The task ID to find
|
||
* @returns {Object|null} The task analysis or null if not found
|
||
*/
|
||
function findTaskInComplexityReport(report, taskId) {
|
||
if (!report || !report.complexityAnalysis || !Array.isArray(report.complexityAnalysis)) {
|
||
return null;
|
||
}
|
||
|
||
return report.complexityAnalysis.find(task => task.taskId === taskId);
|
||
}
|
||
|
||
/**
|
||
* Checks if a task exists in the tasks array
|
||
* @param {Array} tasks - The tasks array
|
||
* @param {string|number} taskId - The task ID to check
|
||
* @returns {boolean} True if the task exists, false otherwise
|
||
*/
|
||
function taskExists(tasks, taskId) {
|
||
if (!taskId || !tasks || !Array.isArray(tasks)) {
|
||
return false;
|
||
}
|
||
|
||
// Handle both regular task IDs and subtask IDs (e.g., "1.2")
|
||
if (typeof taskId === 'string' && taskId.includes('.')) {
|
||
const [parentId, subtaskId] = taskId.split('.').map(id => parseInt(id, 10));
|
||
const parentTask = tasks.find(t => t.id === parentId);
|
||
|
||
if (!parentTask || !parentTask.subtasks) {
|
||
return false;
|
||
}
|
||
|
||
return parentTask.subtasks.some(st => st.id === subtaskId);
|
||
}
|
||
|
||
const id = parseInt(taskId, 10);
|
||
return tasks.some(t => t.id === id);
|
||
}
|
||
|
||
/**
|
||
* Formats a task ID as a string
|
||
* @param {string|number} id - The task ID to format
|
||
* @returns {string} The formatted task ID
|
||
*/
|
||
function formatTaskId(id) {
|
||
if (typeof id === 'string' && id.includes('.')) {
|
||
return id; // Already formatted as a string with a dot (e.g., "1.2")
|
||
}
|
||
|
||
if (typeof id === 'number') {
|
||
return id.toString();
|
||
}
|
||
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* Finds a task by ID in the tasks array
|
||
* @param {Array} tasks - The tasks array
|
||
* @param {string|number} taskId - The task ID to find
|
||
* @returns {Object|null} The task object or null if not found
|
||
*/
|
||
function findTaskById(tasks, taskId) {
|
||
if (!taskId || !tasks || !Array.isArray(tasks)) {
|
||
return null;
|
||
}
|
||
|
||
// Check if it's a subtask ID (e.g., "1.2")
|
||
if (typeof taskId === 'string' && taskId.includes('.')) {
|
||
const [parentId, subtaskId] = taskId.split('.').map(id => parseInt(id, 10));
|
||
const parentTask = tasks.find(t => t.id === parentId);
|
||
|
||
if (!parentTask || !parentTask.subtasks) {
|
||
return null;
|
||
}
|
||
|
||
const subtask = parentTask.subtasks.find(st => st.id === subtaskId);
|
||
if (subtask) {
|
||
// Add reference to parent task for context
|
||
subtask.parentTask = {
|
||
id: parentTask.id,
|
||
title: parentTask.title,
|
||
status: parentTask.status
|
||
};
|
||
subtask.isSubtask = true;
|
||
}
|
||
|
||
return subtask || null;
|
||
}
|
||
|
||
const id = parseInt(taskId, 10);
|
||
return tasks.find(t => t.id === id) || null;
|
||
}
|
||
|
||
/**
|
||
* Truncates text to a specified length
|
||
* @param {string} text - The text to truncate
|
||
* @param {number} maxLength - The maximum length
|
||
* @returns {string} The truncated text
|
||
*/
|
||
function truncate(text, maxLength) {
|
||
if (!text || text.length <= maxLength) {
|
||
return text;
|
||
}
|
||
|
||
return text.slice(0, maxLength - 3) + '...';
|
||
}
|
||
|
||
/**
|
||
* Find cycles in a dependency graph using DFS
|
||
* @param {string} subtaskId - Current subtask ID
|
||
* @param {Map} dependencyMap - Map of subtask IDs to their dependencies
|
||
* @param {Set} visited - Set of visited nodes
|
||
* @param {Set} recursionStack - Set of nodes in current recursion stack
|
||
* @returns {Array} - List of dependency edges that need to be removed to break cycles
|
||
*/
|
||
function findCycles(subtaskId, dependencyMap, visited = new Set(), recursionStack = new Set(), path = []) {
|
||
// Mark the current node as visited and part of recursion stack
|
||
visited.add(subtaskId);
|
||
recursionStack.add(subtaskId);
|
||
path.push(subtaskId);
|
||
|
||
const cyclesToBreak = [];
|
||
|
||
// Get all dependencies of the current subtask
|
||
const dependencies = dependencyMap.get(subtaskId) || [];
|
||
|
||
// For each dependency
|
||
for (const depId of dependencies) {
|
||
// If not visited, recursively check for cycles
|
||
if (!visited.has(depId)) {
|
||
const cycles = findCycles(depId, dependencyMap, visited, recursionStack, [...path]);
|
||
cyclesToBreak.push(...cycles);
|
||
}
|
||
// If the dependency is in the recursion stack, we found a cycle
|
||
else if (recursionStack.has(depId)) {
|
||
// Find the position of the dependency in the path
|
||
const cycleStartIndex = path.indexOf(depId);
|
||
// The last edge in the cycle is what we want to remove
|
||
const cycleEdges = path.slice(cycleStartIndex);
|
||
// We'll remove the last edge in the cycle (the one that points back)
|
||
cyclesToBreak.push(depId);
|
||
}
|
||
}
|
||
|
||
// Remove the node from recursion stack before returning
|
||
recursionStack.delete(subtaskId);
|
||
|
||
return cyclesToBreak;
|
||
}
|
||
|
||
/**
|
||
* Convert a string from camelCase to kebab-case
|
||
* @param {string} str - The string to convert
|
||
* @returns {string} The kebab-case version of the string
|
||
*/
|
||
const toKebabCase = (str) => {
|
||
// Special handling for common acronyms
|
||
const withReplacedAcronyms = str
|
||
.replace(/ID/g, 'Id')
|
||
.replace(/API/g, 'Api')
|
||
.replace(/UI/g, 'Ui')
|
||
.replace(/URL/g, 'Url')
|
||
.replace(/URI/g, 'Uri')
|
||
.replace(/JSON/g, 'Json')
|
||
.replace(/XML/g, 'Xml')
|
||
.replace(/HTML/g, 'Html')
|
||
.replace(/CSS/g, 'Css');
|
||
|
||
// Insert hyphens before capital letters and convert to lowercase
|
||
return withReplacedAcronyms
|
||
.replace(/([A-Z])/g, '-$1')
|
||
.toLowerCase()
|
||
.replace(/^-/, ''); // Remove leading hyphen if present
|
||
};
|
||
|
||
/**
|
||
* Detect camelCase flags in command arguments
|
||
* @param {string[]} args - Command line arguments to check
|
||
* @returns {Array<{original: string, kebabCase: string}>} - List of flags that should be converted
|
||
*/
|
||
function detectCamelCaseFlags(args) {
|
||
const camelCaseFlags = [];
|
||
for (const arg of args) {
|
||
if (arg.startsWith('--')) {
|
||
const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after =
|
||
// Only test for uppercase letters in the flag name
|
||
if (/[A-Z]/.test(flagName)) {
|
||
// Prevent adding duplicate flags or cases where kebab would be same as original
|
||
const kebabVersion = toKebabCase(flagName);
|
||
if (kebabVersion !== flagName) {
|
||
camelCaseFlags.push({
|
||
original: flagName,
|
||
kebabCase: kebabVersion
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return camelCaseFlags;
|
||
}
|
||
|
||
// Export all utility functions and configuration
|
||
export {
|
||
CONFIG,
|
||
LOG_LEVELS,
|
||
log,
|
||
readJSON,
|
||
writeJSON,
|
||
sanitizePrompt,
|
||
readComplexityReport,
|
||
findTaskInComplexityReport,
|
||
taskExists,
|
||
formatTaskId,
|
||
findTaskById,
|
||
truncate,
|
||
findCycles,
|
||
toKebabCase,
|
||
detectCamelCaseFlags
|
||
};
|