claude-task-master/scripts/modules/dependency-manager.js
Eyal Toledano e70f44b6fb fix: Correct handling of dependencies between subtasks
This commit fixes an issue with the dependency management system where
dependencies between subtasks of the same parent task were not being
handled correctly. Previously, when trying to add a dependency between
subtasks (e.g., making 23.8 depend on 23.13), the system incorrectly
interpreted it as a circular dependency of the parent task depending
on itself.

Changes:
- Modified add-dependency and remove-dependency commands to preserve
  string format for subtask IDs containing dots instead of converting
  them to integers
- Updated dependency detection logic to properly handle string-based
  subtask IDs
- Added specific tests verifying both successful dependency chains
  and circular dependency detection between subtasks of the same parent

The fix ensures proper task ordering for development workflows where
related subtasks must be completed in sequence (like establishing tests
before implementing a feature). It maintains backward compatibility with
existing task structures while correctly enforcing dependency chains
within subtask groups.

Tests:
- Added tests for valid dependency chains between subtasks of the same parent
- Added tests for circular dependency detection in subtask relationships
- Added specific test for the 23.8->23.13->23.10 subtask dependency chain

Resolves the issue where the suggested task development workflow couldn't
be properly enforced through the dependency management system.
2025-03-29 18:19:39 -04:00

1124 lines
40 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 - compare full IDs (including subtask parts)
if (String(formattedTaskId) === String(formattedDependencyId)) {
log('error', `Task ${formattedTaskId} cannot depend on itself.`);
process.exit(1);
}
// For subtasks of the same parent, we need to make sure we're not treating it as a self-dependency
// Check if we're dealing with subtasks with the same parent task
let isSelfDependency = false;
if (typeof formattedTaskId === 'string' && typeof formattedDependencyId === 'string' &&
formattedTaskId.includes('.') && formattedDependencyId.includes('.')) {
const [taskParentId] = formattedTaskId.split('.');
const [depParentId] = formattedDependencyId.split('.');
// Only treat it as a self-dependency if both the parent ID and subtask ID are identical
isSelfDependency = formattedTaskId === formattedDependencyId;
// Log for debugging
log('debug', `Adding dependency between subtasks: ${formattedTaskId} depends on ${formattedDependencyId}`);
log('debug', `Parent IDs: ${taskParentId} and ${depParentId}, Self-dependency check: ${isSelfDependency}`);
}
if (isSelfDependency) {
log('error', `Subtask ${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 or subtask
let task = null;
// Check if this is a subtask reference (e.g., "1.2")
if (taskIdStr.includes('.')) {
const [parentId, subtaskId] = taskIdStr.split('.').map(Number);
const parentTask = tasks.find(t => t.id === parentId);
if (parentTask && parentTask.subtasks) {
task = parentTask.subtasks.find(st => st.id === subtaskId);
}
} else {
// Regular task
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`
});
}
// Check subtask dependencies if they exist
if (task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach(subtask => {
if (!subtask.dependencies) {
return; // No dependencies to validate
}
// Create a full subtask ID for reference
const fullSubtaskId = `${task.id}.${subtask.id}`;
subtask.dependencies.forEach(depId => {
// Check for self-dependencies in subtasks
if (String(depId) === String(fullSubtaskId) ||
(typeof depId === 'number' && depId === subtask.id)) {
issues.push({
type: 'self',
taskId: fullSubtaskId,
message: `Subtask ${fullSubtaskId} depends on itself`
});
return;
}
// Check if dependency exists
if (!taskExists(tasks, depId)) {
issues.push({
type: 'missing',
taskId: fullSubtaskId,
dependencyId: depId,
message: `Subtask ${fullSubtaskId} depends on non-existent task/subtask ${depId}`
});
}
});
// Check for circular dependencies in subtasks
if (isCircularDependency(tasks, fullSubtaskId)) {
issues.push({
type: 'circular',
taskId: fullSubtaskId,
message: `Subtask ${fullSubtaskId} 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
}