mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-07-26 10:23:50 +00:00

This commit introduces a comprehensive set of skipped tests to both and . These skipped tests serve as a blueprint for future test implementation, outlining the necessary test cases for currently untested functionalities. - Ensures sync with bin/ folder by adding -r/--research to the command - Fixes an issue that improperly parsed command line args - Ensures confirmation card on dependency add/remove - Properly formats some sub-task dependencies **Potentially addressed issues:** While primarily focused on adding test coverage, this commit also implicitly addresses potential issues by: - **Improving error handling coverage:** The addition of skipped tests for error scenarios in functions like , , , and highlights areas where error handling needs to be robustly tested and potentially improved in the codebase. - **Enhancing dependency validation:** Skipped tests for include validation of dependencies, prompting a review of the dependency validation logic and ensuring its correctness. - **Standardizing test coverage:** By creating a clear roadmap for testing all functions, this commit contributes to a more standardized and complete test suite, reducing the likelihood of undiscovered bugs in the future. **task-manager.test.js:** - Added skipped test blocks for the following functions: - : Includes tests for handling valid JSON responses, malformed JSON, missing tasks in responses, Perplexity AI research integration, Claude fallback, and parallel task processing. - : Covers tests for updating tasks based on context, handling Claude streaming, Perplexity AI integration, scenarios with no tasks to update, and error handling during updates. - : Includes tests for generating task files from , formatting dependencies with status indicators, handling tasks without subtasks, empty task arrays, and dependency validation before file generation. - : Covers tests for updating task status, subtask status using dot notation, updating multiple tasks, automatic subtask status updates, parent task update suggestions, and handling non-existent task IDs. - : Includes tests for updating regular and subtask statuses, handling parent tasks without subtasks, and non-existent subtask IDs. - : Covers tests for displaying all tasks, filtering by status, displaying subtasks, showing completion statistics, identifying the next task, and handling empty task arrays. - : Includes tests for generating subtasks, using complexity reports for subtask counts, Perplexity AI integration, appending subtasks, skipping completed tasks, and error handling during subtask generation. - : Covers tests for expanding all pending tasks, sorting by complexity, skipping tasks with existing subtasks (unless forced), using task-specific parameters from complexity reports, handling empty task arrays, and error handling for individual tasks. - : Includes tests for clearing subtasks from specific and multiple tasks, handling tasks without subtasks, non-existent task IDs, and regenerating task files after clearing subtasks. - : Covers tests for adding new tasks using AI, handling Claude streaming, validating dependencies, handling malformed AI responses, and using existing task context for generation. **utils.test.js:** - Added skipped test blocks for the following functions: - : Tests for logging messages according to log levels and filtering messages below configured levels. - : Tests for reading and parsing valid JSON files, handling file not found errors, and invalid JSON formats. - : Tests for writing JSON data to files and handling file write errors. - : Tests for escaping double quotes in prompts and handling prompts without special characters. - : Tests for reading and parsing complexity reports, handling missing report files, and custom report paths. - : Tests for finding tasks in reports by ID, handling non-existent task IDs, and invalid report structures. - : Tests for verifying existing task and subtask IDs, handling non-existent IDs, and invalid inputs. - : Tests for formatting numeric and string task IDs and preserving dot notation for subtasks. - : Tests for detecting simple and complex cycles in dependency graphs, handling acyclic graphs, and empty dependency maps. These skipped tests provide a clear roadmap for future test development, ensuring comprehensive coverage for core functionalities in both modules. They document the intended behavior of each function and outline various scenarios, including happy paths, edge cases, and error conditions, thereby improving the overall test strategy and maintainability of the Task Master CLI.
1044 lines
37 KiB
JavaScript
1044 lines
37 KiB
JavaScript
/**
|
|
* dependency-manager.js
|
|
* Manages task dependencies and relationships
|
|
*/
|
|
|
|
import path from 'path';
|
|
import chalk from 'chalk';
|
|
import boxen from 'boxen';
|
|
import { Anthropic } from '@anthropic-ai/sdk';
|
|
|
|
import {
|
|
log,
|
|
readJSON,
|
|
writeJSON,
|
|
taskExists,
|
|
formatTaskId,
|
|
findCycles
|
|
} from './utils.js';
|
|
|
|
import { displayBanner } from './ui.js';
|
|
|
|
import { generateTaskFiles } from './task-manager.js';
|
|
|
|
// Initialize Anthropic client
|
|
const anthropic = new Anthropic({
|
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
});
|
|
|
|
|
|
/**
|
|
* Add a dependency to a task
|
|
* @param {string} tasksPath - Path to the tasks.json file
|
|
* @param {number|string} taskId - ID of the task to add dependency to
|
|
* @param {number|string} dependencyId - ID of the task to add as dependency
|
|
*/
|
|
async function addDependency(tasksPath, taskId, dependencyId) {
|
|
log('info', `Adding dependency ${dependencyId} to task ${taskId}...`);
|
|
|
|
const data = readJSON(tasksPath);
|
|
if (!data || !data.tasks) {
|
|
log('error', 'No valid tasks found in tasks.json');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Format the task and dependency IDs correctly
|
|
const formattedTaskId = typeof taskId === 'string' && taskId.includes('.')
|
|
? taskId : parseInt(taskId, 10);
|
|
|
|
const formattedDependencyId = formatTaskId(dependencyId);
|
|
|
|
// Check if the dependency task or subtask actually exists
|
|
if (!taskExists(data.tasks, formattedDependencyId)) {
|
|
log('error', `Dependency target ${formattedDependencyId} does not exist in tasks.json`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Find the task to update
|
|
let targetTask = null;
|
|
let isSubtask = false;
|
|
|
|
if (typeof formattedTaskId === 'string' && formattedTaskId.includes('.')) {
|
|
// Handle dot notation for subtasks (e.g., "1.2")
|
|
const [parentId, subtaskId] = formattedTaskId.split('.').map(id => parseInt(id, 10));
|
|
const parentTask = data.tasks.find(t => t.id === parentId);
|
|
|
|
if (!parentTask) {
|
|
log('error', `Parent task ${parentId} not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!parentTask.subtasks) {
|
|
log('error', `Parent task ${parentId} has no subtasks.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
targetTask = parentTask.subtasks.find(s => s.id === subtaskId);
|
|
isSubtask = true;
|
|
|
|
if (!targetTask) {
|
|
log('error', `Subtask ${formattedTaskId} not found.`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
// Regular task (not a subtask)
|
|
targetTask = data.tasks.find(t => t.id === formattedTaskId);
|
|
|
|
if (!targetTask) {
|
|
log('error', `Task ${formattedTaskId} not found.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Initialize dependencies array if it doesn't exist
|
|
if (!targetTask.dependencies) {
|
|
targetTask.dependencies = [];
|
|
}
|
|
|
|
// Check if dependency already exists
|
|
if (targetTask.dependencies.some(d => {
|
|
// Convert both to strings for comparison to handle both numeric and string IDs
|
|
return String(d) === String(formattedDependencyId);
|
|
})) {
|
|
log('warn', `Dependency ${formattedDependencyId} already exists in task ${formattedTaskId}.`);
|
|
return;
|
|
}
|
|
|
|
// Check if the task is trying to depend on itself
|
|
if (String(formattedTaskId) === String(formattedDependencyId)) {
|
|
log('error', `Task ${formattedTaskId} cannot depend on itself.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check for circular dependencies
|
|
let dependencyChain = [formattedTaskId];
|
|
if (!isCircularDependency(data.tasks, formattedDependencyId, dependencyChain)) {
|
|
// Add the dependency
|
|
targetTask.dependencies.push(formattedDependencyId);
|
|
|
|
// Sort dependencies numerically or by parent task ID first, then subtask ID
|
|
targetTask.dependencies.sort((a, b) => {
|
|
if (typeof a === 'number' && typeof b === 'number') {
|
|
return a - b;
|
|
} else if (typeof a === 'string' && typeof b === 'string') {
|
|
const [aParent, aChild] = a.split('.').map(Number);
|
|
const [bParent, bChild] = b.split('.').map(Number);
|
|
return aParent !== bParent ? aParent - bParent : aChild - bChild;
|
|
} else if (typeof a === 'number') {
|
|
return -1; // Numbers come before strings
|
|
} else {
|
|
return 1; // Strings come after numbers
|
|
}
|
|
});
|
|
|
|
// Save changes
|
|
writeJSON(tasksPath, data);
|
|
log('success', `Added dependency ${formattedDependencyId} to task ${formattedTaskId}`);
|
|
|
|
// Display a more visually appealing success message
|
|
console.log(boxen(
|
|
chalk.green(`Successfully added dependency:\n\n`) +
|
|
`Task ${chalk.bold(formattedTaskId)} now depends on ${chalk.bold(formattedDependencyId)}`,
|
|
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } }
|
|
));
|
|
|
|
// Generate updated task files
|
|
await generateTaskFiles(tasksPath, 'tasks');
|
|
|
|
log('info', 'Task files regenerated with updated dependencies.');
|
|
} else {
|
|
log('error', `Cannot add dependency ${formattedDependencyId} to task ${formattedTaskId} as it would create a circular dependency.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a dependency from a task
|
|
* @param {string} tasksPath - Path to the tasks.json file
|
|
* @param {number|string} taskId - ID of the task to remove dependency from
|
|
* @param {number|string} dependencyId - ID of the task to remove as dependency
|
|
*/
|
|
async function removeDependency(tasksPath, taskId, dependencyId) {
|
|
log('info', `Removing dependency ${dependencyId} from task ${taskId}...`);
|
|
|
|
// Read tasks file
|
|
const data = readJSON(tasksPath);
|
|
if (!data || !data.tasks) {
|
|
log('error', "No valid tasks found.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Format the task and dependency IDs correctly
|
|
const formattedTaskId = typeof taskId === 'string' && taskId.includes('.')
|
|
? taskId : parseInt(taskId, 10);
|
|
|
|
const formattedDependencyId = formatTaskId(dependencyId);
|
|
|
|
// Find the task to update
|
|
let targetTask = null;
|
|
let isSubtask = false;
|
|
|
|
if (typeof formattedTaskId === 'string' && formattedTaskId.includes('.')) {
|
|
// Handle dot notation for subtasks (e.g., "1.2")
|
|
const [parentId, subtaskId] = formattedTaskId.split('.').map(id => parseInt(id, 10));
|
|
const parentTask = data.tasks.find(t => t.id === parentId);
|
|
|
|
if (!parentTask) {
|
|
log('error', `Parent task ${parentId} not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!parentTask.subtasks) {
|
|
log('error', `Parent task ${parentId} has no subtasks.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
targetTask = parentTask.subtasks.find(s => s.id === subtaskId);
|
|
isSubtask = true;
|
|
|
|
if (!targetTask) {
|
|
log('error', `Subtask ${formattedTaskId} not found.`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
// Regular task (not a subtask)
|
|
targetTask = data.tasks.find(t => t.id === formattedTaskId);
|
|
|
|
if (!targetTask) {
|
|
log('error', `Task ${formattedTaskId} not found.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Check if the task has any dependencies
|
|
if (!targetTask.dependencies || targetTask.dependencies.length === 0) {
|
|
log('info', `Task ${formattedTaskId} has no dependencies, nothing to remove.`);
|
|
return;
|
|
}
|
|
|
|
// Normalize the dependency ID for comparison to handle different formats
|
|
const normalizedDependencyId = String(formattedDependencyId);
|
|
|
|
// Check if the dependency exists by comparing string representations
|
|
const dependencyIndex = targetTask.dependencies.findIndex(dep => {
|
|
// Convert both to strings for comparison
|
|
let depStr = String(dep);
|
|
|
|
// Special handling for numeric IDs that might be subtask references
|
|
if (typeof dep === 'number' && dep < 100 && isSubtask) {
|
|
// It's likely a reference to another subtask in the same parent task
|
|
// Convert to full format for comparison (e.g., 2 -> "1.2" for a subtask in task 1)
|
|
const [parentId] = formattedTaskId.split('.');
|
|
depStr = `${parentId}.${dep}`;
|
|
}
|
|
|
|
return depStr === normalizedDependencyId;
|
|
});
|
|
|
|
if (dependencyIndex === -1) {
|
|
log('info', `Task ${formattedTaskId} does not depend on ${formattedDependencyId}, no changes made.`);
|
|
return;
|
|
}
|
|
|
|
// Remove the dependency
|
|
targetTask.dependencies.splice(dependencyIndex, 1);
|
|
|
|
// Save the updated tasks
|
|
writeJSON(tasksPath, data);
|
|
|
|
// Success message
|
|
log('success', `Removed dependency: Task ${formattedTaskId} no longer depends on ${formattedDependencyId}`);
|
|
|
|
// Display a more visually appealing success message
|
|
console.log(boxen(
|
|
chalk.green(`Successfully removed dependency:\n\n`) +
|
|
`Task ${chalk.bold(formattedTaskId)} no longer depends on ${chalk.bold(formattedDependencyId)}`,
|
|
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } }
|
|
));
|
|
|
|
// Regenerate task files
|
|
await generateTaskFiles(tasksPath, 'tasks');
|
|
}
|
|
|
|
/**
|
|
* Check if adding a dependency would create a circular dependency
|
|
* @param {Array} tasks - Array of all tasks
|
|
* @param {number|string} taskId - ID of task to check
|
|
* @param {Array} chain - Chain of dependencies to check
|
|
* @returns {boolean} True if circular dependency would be created
|
|
*/
|
|
function isCircularDependency(tasks, taskId, chain = []) {
|
|
// Convert taskId to string for comparison
|
|
const taskIdStr = String(taskId);
|
|
|
|
// If we've seen this task before in the chain, we have a circular dependency
|
|
if (chain.some(id => String(id) === taskIdStr)) {
|
|
return true;
|
|
}
|
|
|
|
// Find the task
|
|
const task = tasks.find(t => String(t.id) === taskIdStr);
|
|
if (!task) {
|
|
return false; // Task doesn't exist, can't create circular dependency
|
|
}
|
|
|
|
// No dependencies, can't create circular dependency
|
|
if (!task.dependencies || task.dependencies.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Check each dependency recursively
|
|
const newChain = [...chain, taskId];
|
|
return task.dependencies.some(depId => isCircularDependency(tasks, depId, newChain));
|
|
}
|
|
|
|
/**
|
|
* Validate task dependencies
|
|
* @param {Array} tasks - Array of all tasks
|
|
* @returns {Object} Validation result with valid flag and issues array
|
|
*/
|
|
function validateTaskDependencies(tasks) {
|
|
const issues = [];
|
|
|
|
// Check each task's dependencies
|
|
tasks.forEach(task => {
|
|
if (!task.dependencies) {
|
|
return; // No dependencies to validate
|
|
}
|
|
|
|
task.dependencies.forEach(depId => {
|
|
// Check for self-dependencies
|
|
if (String(depId) === String(task.id)) {
|
|
issues.push({
|
|
type: 'self',
|
|
taskId: task.id,
|
|
message: `Task ${task.id} depends on itself`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if dependency exists
|
|
if (!taskExists(tasks, depId)) {
|
|
issues.push({
|
|
type: 'missing',
|
|
taskId: task.id,
|
|
dependencyId: depId,
|
|
message: `Task ${task.id} depends on non-existent task ${depId}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for circular dependencies
|
|
if (isCircularDependency(tasks, task.id)) {
|
|
issues.push({
|
|
type: 'circular',
|
|
taskId: task.id,
|
|
message: `Task ${task.id} is part of a circular dependency chain`
|
|
});
|
|
}
|
|
});
|
|
|
|
return {
|
|
valid: issues.length === 0,
|
|
issues
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove duplicate dependencies from tasks
|
|
* @param {Object} tasksData - Tasks data object with tasks array
|
|
* @returns {Object} Updated tasks data with duplicates removed
|
|
*/
|
|
function removeDuplicateDependencies(tasksData) {
|
|
const tasks = tasksData.tasks.map(task => {
|
|
if (!task.dependencies) {
|
|
return task;
|
|
}
|
|
|
|
// Convert to Set and back to array to remove duplicates
|
|
const uniqueDeps = [...new Set(task.dependencies)];
|
|
return {
|
|
...task,
|
|
dependencies: uniqueDeps
|
|
};
|
|
});
|
|
|
|
return {
|
|
...tasksData,
|
|
tasks
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clean up invalid subtask dependencies
|
|
* @param {Object} tasksData - Tasks data object with tasks array
|
|
* @returns {Object} Updated tasks data with invalid subtask dependencies removed
|
|
*/
|
|
function cleanupSubtaskDependencies(tasksData) {
|
|
const tasks = tasksData.tasks.map(task => {
|
|
// Handle task's own dependencies
|
|
if (task.dependencies) {
|
|
task.dependencies = task.dependencies.filter(depId => {
|
|
// Keep only dependencies that exist
|
|
return taskExists(tasksData.tasks, depId);
|
|
});
|
|
}
|
|
|
|
// Handle subtask dependencies
|
|
if (task.subtasks) {
|
|
task.subtasks = task.subtasks.map(subtask => {
|
|
if (!subtask.dependencies) {
|
|
return subtask;
|
|
}
|
|
|
|
// Filter out dependencies to non-existent subtasks
|
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
|
return taskExists(tasksData.tasks, depId);
|
|
});
|
|
|
|
return subtask;
|
|
});
|
|
}
|
|
|
|
return task;
|
|
});
|
|
|
|
return {
|
|
...tasksData,
|
|
tasks
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate dependencies in task files
|
|
* @param {string} tasksPath - Path to tasks.json
|
|
*/
|
|
async function validateDependenciesCommand(tasksPath) {
|
|
displayBanner();
|
|
|
|
log('info', 'Checking for invalid dependencies in task files...');
|
|
|
|
// Read tasks data
|
|
const data = readJSON(tasksPath);
|
|
if (!data || !data.tasks) {
|
|
log('error', 'No valid tasks found in tasks.json');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Count of tasks and subtasks for reporting
|
|
const taskCount = data.tasks.length;
|
|
let subtaskCount = 0;
|
|
data.tasks.forEach(task => {
|
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
|
subtaskCount += task.subtasks.length;
|
|
}
|
|
});
|
|
|
|
log('info', `Analyzing dependencies for ${taskCount} tasks and ${subtaskCount} subtasks...`);
|
|
|
|
// Track validation statistics
|
|
const stats = {
|
|
nonExistentDependenciesRemoved: 0,
|
|
selfDependenciesRemoved: 0,
|
|
tasksFixed: 0,
|
|
subtasksFixed: 0
|
|
};
|
|
|
|
// Create a custom logger instead of reassigning the imported log function
|
|
const warnings = [];
|
|
const customLogger = function(level, ...args) {
|
|
if (level === 'warn') {
|
|
warnings.push(args.join(' '));
|
|
|
|
// Count the type of fix based on the warning message
|
|
const msg = args.join(' ');
|
|
if (msg.includes('self-dependency')) {
|
|
stats.selfDependenciesRemoved++;
|
|
} else if (msg.includes('invalid')) {
|
|
stats.nonExistentDependenciesRemoved++;
|
|
}
|
|
|
|
// Count if it's a task or subtask being fixed
|
|
if (msg.includes('from subtask')) {
|
|
stats.subtasksFixed++;
|
|
} else if (msg.includes('from task')) {
|
|
stats.tasksFixed++;
|
|
}
|
|
}
|
|
// Call the original log function
|
|
return log(level, ...args);
|
|
};
|
|
|
|
// Run validation with custom logger
|
|
try {
|
|
// Temporarily save validateTaskDependencies function with normal log
|
|
const originalValidateTaskDependencies = validateTaskDependencies;
|
|
|
|
// Create patched version that uses customLogger
|
|
const patchedValidateTaskDependencies = (tasks, tasksPath) => {
|
|
// Temporarily redirect log calls in this scope
|
|
const originalLog = log;
|
|
const logProxy = function(...args) {
|
|
return customLogger(...args);
|
|
};
|
|
|
|
// Call the original function in a context where log calls are intercepted
|
|
const result = (() => {
|
|
// Use Function.prototype.bind to create a new function that has logProxy available
|
|
return Function('tasks', 'tasksPath', 'log', 'customLogger',
|
|
`return (${originalValidateTaskDependencies.toString()})(tasks, tasksPath);`
|
|
)(tasks, tasksPath, logProxy, customLogger);
|
|
})();
|
|
|
|
return result;
|
|
};
|
|
|
|
const changesDetected = patchedValidateTaskDependencies(data.tasks, tasksPath);
|
|
|
|
// Create a detailed report
|
|
if (changesDetected) {
|
|
log('success', 'Invalid dependencies were removed from tasks.json');
|
|
|
|
// Show detailed stats in a nice box
|
|
console.log(boxen(
|
|
chalk.green(`Dependency Validation Results:\n\n`) +
|
|
`${chalk.cyan('Tasks checked:')} ${taskCount}\n` +
|
|
`${chalk.cyan('Subtasks checked:')} ${subtaskCount}\n` +
|
|
`${chalk.cyan('Non-existent dependencies removed:')} ${stats.nonExistentDependenciesRemoved}\n` +
|
|
`${chalk.cyan('Self-dependencies removed:')} ${stats.selfDependenciesRemoved}\n` +
|
|
`${chalk.cyan('Tasks fixed:')} ${stats.tasksFixed}\n` +
|
|
`${chalk.cyan('Subtasks fixed:')} ${stats.subtasksFixed}`,
|
|
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } }
|
|
));
|
|
|
|
// Show all warnings in a collapsible list if there are many
|
|
if (warnings.length > 0) {
|
|
console.log(chalk.yellow('\nDetailed fixes:'));
|
|
warnings.forEach(warning => {
|
|
console.log(` ${warning}`);
|
|
});
|
|
}
|
|
|
|
// Regenerate task files to reflect the changes
|
|
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
|
|
log('info', 'Task files regenerated to reflect dependency changes');
|
|
} else {
|
|
log('success', 'No invalid dependencies found - all dependencies are valid');
|
|
|
|
// Show validation summary
|
|
console.log(boxen(
|
|
chalk.green(`All Dependencies Are Valid\n\n`) +
|
|
`${chalk.cyan('Tasks checked:')} ${taskCount}\n` +
|
|
`${chalk.cyan('Subtasks checked:')} ${subtaskCount}\n` +
|
|
`${chalk.cyan('Total dependencies verified:')} ${countAllDependencies(data.tasks)}`,
|
|
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } }
|
|
));
|
|
}
|
|
} catch (error) {
|
|
log('error', 'Error validating dependencies:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function to count all dependencies across tasks and subtasks
|
|
* @param {Array} tasks - All tasks
|
|
* @returns {number} - Total number of dependencies
|
|
*/
|
|
function countAllDependencies(tasks) {
|
|
let count = 0;
|
|
|
|
tasks.forEach(task => {
|
|
// Count main task dependencies
|
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
|
count += task.dependencies.length;
|
|
}
|
|
|
|
// Count subtask dependencies
|
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
|
task.subtasks.forEach(subtask => {
|
|
if (subtask.dependencies && Array.isArray(subtask.dependencies)) {
|
|
count += subtask.dependencies.length;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Fixes invalid dependencies in tasks.json
|
|
* @param {string} tasksPath - Path to tasks.json
|
|
*/
|
|
async function fixDependenciesCommand(tasksPath) {
|
|
displayBanner();
|
|
|
|
log('info', 'Checking for and fixing invalid dependencies in tasks.json...');
|
|
|
|
try {
|
|
// Read tasks data
|
|
const data = readJSON(tasksPath);
|
|
if (!data || !data.tasks) {
|
|
log('error', 'No valid tasks found in tasks.json');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Create a deep copy of the original data for comparison
|
|
const originalData = JSON.parse(JSON.stringify(data));
|
|
|
|
// Track fixes for reporting
|
|
const stats = {
|
|
nonExistentDependenciesRemoved: 0,
|
|
selfDependenciesRemoved: 0,
|
|
duplicateDependenciesRemoved: 0,
|
|
circularDependenciesFixed: 0,
|
|
tasksFixed: 0,
|
|
subtasksFixed: 0
|
|
};
|
|
|
|
// First phase: Remove duplicate dependencies in tasks
|
|
data.tasks.forEach(task => {
|
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
|
const uniqueDeps = new Set();
|
|
const originalLength = task.dependencies.length;
|
|
task.dependencies = task.dependencies.filter(depId => {
|
|
const depIdStr = String(depId);
|
|
if (uniqueDeps.has(depIdStr)) {
|
|
log('info', `Removing duplicate dependency from task ${task.id}: ${depId}`);
|
|
stats.duplicateDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
uniqueDeps.add(depIdStr);
|
|
return true;
|
|
});
|
|
if (task.dependencies.length < originalLength) {
|
|
stats.tasksFixed++;
|
|
}
|
|
}
|
|
|
|
// Check for duplicates in subtasks
|
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
|
task.subtasks.forEach(subtask => {
|
|
if (subtask.dependencies && Array.isArray(subtask.dependencies)) {
|
|
const uniqueDeps = new Set();
|
|
const originalLength = subtask.dependencies.length;
|
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
|
let depIdStr = String(depId);
|
|
if (typeof depId === 'number' && depId < 100) {
|
|
depIdStr = `${task.id}.${depId}`;
|
|
}
|
|
if (uniqueDeps.has(depIdStr)) {
|
|
log('info', `Removing duplicate dependency from subtask ${task.id}.${subtask.id}: ${depId}`);
|
|
stats.duplicateDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
uniqueDeps.add(depIdStr);
|
|
return true;
|
|
});
|
|
if (subtask.dependencies.length < originalLength) {
|
|
stats.subtasksFixed++;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Create validity maps for tasks and subtasks
|
|
const validTaskIds = new Set(data.tasks.map(t => t.id));
|
|
const validSubtaskIds = new Set();
|
|
data.tasks.forEach(task => {
|
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
|
task.subtasks.forEach(subtask => {
|
|
validSubtaskIds.add(`${task.id}.${subtask.id}`);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Second phase: Remove invalid task dependencies (non-existent tasks)
|
|
data.tasks.forEach(task => {
|
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
|
const originalLength = task.dependencies.length;
|
|
task.dependencies = task.dependencies.filter(depId => {
|
|
const isSubtask = typeof depId === 'string' && depId.includes('.');
|
|
|
|
if (isSubtask) {
|
|
// Check if the subtask exists
|
|
if (!validSubtaskIds.has(depId)) {
|
|
log('info', `Removing invalid subtask dependency from task ${task.id}: ${depId} (subtask does not exist)`);
|
|
stats.nonExistentDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
return true;
|
|
} else {
|
|
// Check if the task exists
|
|
const numericId = typeof depId === 'string' ? parseInt(depId, 10) : depId;
|
|
if (!validTaskIds.has(numericId)) {
|
|
log('info', `Removing invalid task dependency from task ${task.id}: ${depId} (task does not exist)`);
|
|
stats.nonExistentDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
});
|
|
|
|
if (task.dependencies.length < originalLength) {
|
|
stats.tasksFixed++;
|
|
}
|
|
}
|
|
|
|
// Check subtask dependencies for invalid references
|
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
|
task.subtasks.forEach(subtask => {
|
|
if (subtask.dependencies && Array.isArray(subtask.dependencies)) {
|
|
const originalLength = subtask.dependencies.length;
|
|
const subtaskId = `${task.id}.${subtask.id}`;
|
|
|
|
// First check for self-dependencies
|
|
const hasSelfDependency = subtask.dependencies.some(depId => {
|
|
if (typeof depId === 'string' && depId.includes('.')) {
|
|
return depId === subtaskId;
|
|
} else if (typeof depId === 'number' && depId < 100) {
|
|
return depId === subtask.id;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (hasSelfDependency) {
|
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
|
const normalizedDepId = typeof depId === 'number' && depId < 100
|
|
? `${task.id}.${depId}`
|
|
: String(depId);
|
|
|
|
if (normalizedDepId === subtaskId) {
|
|
log('info', `Removing self-dependency from subtask ${subtaskId}`);
|
|
stats.selfDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Then check for non-existent dependencies
|
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
|
if (typeof depId === 'string' && depId.includes('.')) {
|
|
if (!validSubtaskIds.has(depId)) {
|
|
log('info', `Removing invalid subtask dependency from subtask ${subtaskId}: ${depId} (subtask does not exist)`);
|
|
stats.nonExistentDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Handle numeric dependencies
|
|
const numericId = typeof depId === 'number' ? depId : parseInt(depId, 10);
|
|
|
|
// Small numbers likely refer to subtasks in the same task
|
|
if (numericId < 100) {
|
|
const fullSubtaskId = `${task.id}.${numericId}`;
|
|
|
|
if (!validSubtaskIds.has(fullSubtaskId)) {
|
|
log('info', `Removing invalid subtask dependency from subtask ${subtaskId}: ${numericId}`);
|
|
stats.nonExistentDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Otherwise it's a task reference
|
|
if (!validTaskIds.has(numericId)) {
|
|
log('info', `Removing invalid task dependency from subtask ${subtaskId}: ${numericId}`);
|
|
stats.nonExistentDependenciesRemoved++;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (subtask.dependencies.length < originalLength) {
|
|
stats.subtasksFixed++;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Third phase: Check for circular dependencies
|
|
log('info', 'Checking for circular dependencies...');
|
|
|
|
// Build the dependency map for subtasks
|
|
const subtaskDependencyMap = new Map();
|
|
data.tasks.forEach(task => {
|
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
|
task.subtasks.forEach(subtask => {
|
|
const subtaskId = `${task.id}.${subtask.id}`;
|
|
|
|
if (subtask.dependencies && Array.isArray(subtask.dependencies)) {
|
|
const normalizedDeps = subtask.dependencies.map(depId => {
|
|
if (typeof depId === 'string' && depId.includes('.')) {
|
|
return depId;
|
|
} else if (typeof depId === 'number' && depId < 100) {
|
|
return `${task.id}.${depId}`;
|
|
}
|
|
return String(depId);
|
|
});
|
|
subtaskDependencyMap.set(subtaskId, normalizedDeps);
|
|
} else {
|
|
subtaskDependencyMap.set(subtaskId, []);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for and fix circular dependencies
|
|
for (const [subtaskId, dependencies] of subtaskDependencyMap.entries()) {
|
|
const visited = new Set();
|
|
const recursionStack = new Set();
|
|
|
|
// Detect cycles
|
|
const cycleEdges = findCycles(subtaskId, subtaskDependencyMap, visited, recursionStack);
|
|
|
|
if (cycleEdges.length > 0) {
|
|
const [taskId, subtaskNum] = subtaskId.split('.').map(part => Number(part));
|
|
const task = data.tasks.find(t => t.id === taskId);
|
|
|
|
if (task && task.subtasks) {
|
|
const subtask = task.subtasks.find(st => st.id === subtaskNum);
|
|
|
|
if (subtask && subtask.dependencies) {
|
|
const originalLength = subtask.dependencies.length;
|
|
|
|
const edgesToRemove = cycleEdges.map(edge => {
|
|
if (edge.includes('.')) {
|
|
const [depTaskId, depSubtaskId] = edge.split('.').map(part => Number(part));
|
|
|
|
if (depTaskId === taskId) {
|
|
return depSubtaskId;
|
|
}
|
|
|
|
return edge;
|
|
}
|
|
|
|
return Number(edge);
|
|
});
|
|
|
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
|
const normalizedDepId = typeof depId === 'number' && depId < 100
|
|
? `${taskId}.${depId}`
|
|
: String(depId);
|
|
|
|
if (edgesToRemove.includes(depId) || edgesToRemove.includes(normalizedDepId)) {
|
|
log('info', `Breaking circular dependency: Removing ${normalizedDepId} from subtask ${subtaskId}`);
|
|
stats.circularDependenciesFixed++;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (subtask.dependencies.length < originalLength) {
|
|
stats.subtasksFixed++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if any changes were made by comparing with original data
|
|
const dataChanged = JSON.stringify(data) !== JSON.stringify(originalData);
|
|
|
|
if (dataChanged) {
|
|
// Save the changes
|
|
writeJSON(tasksPath, data);
|
|
log('success', 'Fixed dependency issues in tasks.json');
|
|
|
|
// Regenerate task files
|
|
log('info', 'Regenerating task files to reflect dependency changes...');
|
|
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
|
|
} else {
|
|
log('info', 'No changes needed to fix dependencies');
|
|
}
|
|
|
|
// Show detailed statistics report
|
|
const totalFixedAll = stats.nonExistentDependenciesRemoved +
|
|
stats.selfDependenciesRemoved +
|
|
stats.duplicateDependenciesRemoved +
|
|
stats.circularDependenciesFixed;
|
|
|
|
if (totalFixedAll > 0) {
|
|
log('success', `Fixed ${totalFixedAll} dependency issues in total!`);
|
|
|
|
console.log(boxen(
|
|
chalk.green(`Dependency Fixes Summary:\n\n`) +
|
|
`${chalk.cyan('Invalid dependencies removed:')} ${stats.nonExistentDependenciesRemoved}\n` +
|
|
`${chalk.cyan('Self-dependencies removed:')} ${stats.selfDependenciesRemoved}\n` +
|
|
`${chalk.cyan('Duplicate dependencies removed:')} ${stats.duplicateDependenciesRemoved}\n` +
|
|
`${chalk.cyan('Circular dependencies fixed:')} ${stats.circularDependenciesFixed}\n\n` +
|
|
`${chalk.cyan('Tasks fixed:')} ${stats.tasksFixed}\n` +
|
|
`${chalk.cyan('Subtasks fixed:')} ${stats.subtasksFixed}\n`,
|
|
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } }
|
|
));
|
|
} else {
|
|
log('success', 'No dependency issues found - all dependencies are valid');
|
|
|
|
console.log(boxen(
|
|
chalk.green(`All Dependencies Are Valid\n\n`) +
|
|
`${chalk.cyan('Tasks checked:')} ${data.tasks.length}\n` +
|
|
`${chalk.cyan('Total dependencies verified:')} ${countAllDependencies(data.tasks)}`,
|
|
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } }
|
|
));
|
|
}
|
|
} catch (error) {
|
|
log('error', "Error in fix-dependencies command:", error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure at least one subtask in each task has no dependencies
|
|
* @param {Object} tasksData - The tasks data object with tasks array
|
|
* @returns {boolean} - True if any changes were made
|
|
*/
|
|
function ensureAtLeastOneIndependentSubtask(tasksData) {
|
|
if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) {
|
|
return false;
|
|
}
|
|
|
|
let changesDetected = false;
|
|
|
|
tasksData.tasks.forEach(task => {
|
|
if (!task.subtasks || !Array.isArray(task.subtasks) || task.subtasks.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Check if any subtask has no dependencies
|
|
const hasIndependentSubtask = task.subtasks.some(st =>
|
|
!st.dependencies || !Array.isArray(st.dependencies) || st.dependencies.length === 0
|
|
);
|
|
|
|
if (!hasIndependentSubtask) {
|
|
// Find the first subtask and clear its dependencies
|
|
if (task.subtasks.length > 0) {
|
|
const firstSubtask = task.subtasks[0];
|
|
log('debug', `Ensuring at least one independent subtask: Clearing dependencies for subtask ${task.id}.${firstSubtask.id}`);
|
|
firstSubtask.dependencies = [];
|
|
changesDetected = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
return changesDetected;
|
|
}
|
|
|
|
/**
|
|
* Validate and fix dependencies across all tasks and subtasks
|
|
* This function is designed to be called after any task modification
|
|
* @param {Object} tasksData - The tasks data object with tasks array
|
|
* @param {string} tasksPath - Optional path to save the changes
|
|
* @returns {boolean} - True if any changes were made
|
|
*/
|
|
function validateAndFixDependencies(tasksData, tasksPath = null) {
|
|
if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) {
|
|
log('error', 'Invalid tasks data');
|
|
return false;
|
|
}
|
|
|
|
log('debug', 'Validating and fixing dependencies...');
|
|
|
|
// Create a deep copy for comparison
|
|
const originalData = JSON.parse(JSON.stringify(tasksData));
|
|
|
|
// 1. Remove duplicate dependencies from tasks and subtasks
|
|
tasksData.tasks = tasksData.tasks.map(task => {
|
|
// Handle task dependencies
|
|
if (task.dependencies) {
|
|
const uniqueDeps = [...new Set(task.dependencies)];
|
|
task.dependencies = uniqueDeps;
|
|
}
|
|
|
|
// Handle subtask dependencies
|
|
if (task.subtasks) {
|
|
task.subtasks = task.subtasks.map(subtask => {
|
|
if (subtask.dependencies) {
|
|
const uniqueDeps = [...new Set(subtask.dependencies)];
|
|
subtask.dependencies = uniqueDeps;
|
|
}
|
|
return subtask;
|
|
});
|
|
}
|
|
return task;
|
|
});
|
|
|
|
// 2. Remove invalid task dependencies (non-existent tasks)
|
|
tasksData.tasks.forEach(task => {
|
|
// Clean up task dependencies
|
|
if (task.dependencies) {
|
|
task.dependencies = task.dependencies.filter(depId => {
|
|
// Remove self-dependencies
|
|
if (String(depId) === String(task.id)) {
|
|
return false;
|
|
}
|
|
// Remove non-existent dependencies
|
|
return taskExists(tasksData.tasks, depId);
|
|
});
|
|
}
|
|
|
|
// Clean up subtask dependencies
|
|
if (task.subtasks) {
|
|
task.subtasks.forEach(subtask => {
|
|
if (subtask.dependencies) {
|
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
|
// Handle numeric subtask references
|
|
if (typeof depId === 'number' && depId < 100) {
|
|
const fullSubtaskId = `${task.id}.${depId}`;
|
|
return taskExists(tasksData.tasks, fullSubtaskId);
|
|
}
|
|
// Handle full task/subtask references
|
|
return taskExists(tasksData.tasks, depId);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// 3. Ensure at least one subtask has no dependencies in each task
|
|
tasksData.tasks.forEach(task => {
|
|
if (task.subtasks && task.subtasks.length > 0) {
|
|
const hasIndependentSubtask = task.subtasks.some(st =>
|
|
!st.dependencies || !Array.isArray(st.dependencies) || st.dependencies.length === 0
|
|
);
|
|
|
|
if (!hasIndependentSubtask) {
|
|
task.subtasks[0].dependencies = [];
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check if any changes were made by comparing with original data
|
|
const changesDetected = JSON.stringify(tasksData) !== JSON.stringify(originalData);
|
|
|
|
// Save changes if needed
|
|
if (tasksPath && changesDetected) {
|
|
try {
|
|
writeJSON(tasksPath, tasksData);
|
|
log('debug', 'Saved dependency fixes to tasks.json');
|
|
} catch (error) {
|
|
log('error', 'Failed to save dependency fixes to tasks.json', error);
|
|
}
|
|
}
|
|
|
|
return changesDetected;
|
|
}
|
|
|
|
export {
|
|
addDependency,
|
|
removeDependency,
|
|
isCircularDependency,
|
|
validateTaskDependencies,
|
|
validateDependenciesCommand,
|
|
fixDependenciesCommand,
|
|
removeDuplicateDependencies,
|
|
cleanupSubtaskDependencies,
|
|
ensureAtLeastOneIndependentSubtask,
|
|
validateAndFixDependencies
|
|
} |