mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-07-03 23:19:44 +00:00
1326 lines
49 KiB
JavaScript
1326 lines
49 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}`);
|
||
|
|
||
|
// 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 - All tasks
|
||
|
* @param {number|string} dependencyId - ID of the dependency being added
|
||
|
* @param {Array} chain - Current dependency chain being checked
|
||
|
* @returns {boolean} - True if circular dependency would be created, false otherwise
|
||
|
*/
|
||
|
function isCircularDependency(tasks, dependencyId, chain = []) {
|
||
|
// Convert chain elements and dependencyId to strings for consistent comparison
|
||
|
const chainStrs = chain.map(id => String(id));
|
||
|
const depIdStr = String(dependencyId);
|
||
|
|
||
|
// If the dependency is already in the chain, it would create a circular dependency
|
||
|
if (chainStrs.includes(depIdStr)) {
|
||
|
log('error', `Circular dependency detected: ${chainStrs.join(' -> ')} -> ${depIdStr}`);
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// Check if this is a subtask dependency (e.g., "1.2")
|
||
|
const isSubtask = depIdStr.includes('.');
|
||
|
|
||
|
// Find the task or subtask by ID
|
||
|
let dependencyTask = null;
|
||
|
let dependencySubtask = null;
|
||
|
|
||
|
if (isSubtask) {
|
||
|
// Parse parent and subtask IDs
|
||
|
const [parentId, subtaskId] = depIdStr.split('.').map(id => isNaN(id) ? id : Number(id));
|
||
|
const parentTask = tasks.find(t => t.id === parentId);
|
||
|
|
||
|
if (parentTask && parentTask.subtasks) {
|
||
|
dependencySubtask = parentTask.subtasks.find(s => s.id === Number(subtaskId));
|
||
|
// For a subtask, we need to check dependencies of both the subtask and its parent
|
||
|
if (dependencySubtask && dependencySubtask.dependencies && dependencySubtask.dependencies.length > 0) {
|
||
|
// Recursively check each of the subtask's dependencies
|
||
|
const newChain = [...chainStrs, depIdStr];
|
||
|
const hasCircular = dependencySubtask.dependencies.some(depId => {
|
||
|
// Handle relative subtask references (e.g., numeric IDs referring to subtasks in the same parent task)
|
||
|
const normalizedDepId = typeof depId === 'number' && depId < 100
|
||
|
? `${parentId}.${depId}`
|
||
|
: depId;
|
||
|
return isCircularDependency(tasks, normalizedDepId, newChain);
|
||
|
});
|
||
|
|
||
|
if (hasCircular) return true;
|
||
|
}
|
||
|
|
||
|
// Also check if parent task has dependencies that could create a cycle
|
||
|
if (parentTask.dependencies && parentTask.dependencies.length > 0) {
|
||
|
// If any of the parent's dependencies create a cycle, return true
|
||
|
const newChain = [...chainStrs, depIdStr];
|
||
|
if (parentTask.dependencies.some(depId => isCircularDependency(tasks, depId, newChain))) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
} else {
|
||
|
// Regular task (not a subtask)
|
||
|
const depId = isNaN(dependencyId) ? dependencyId : Number(dependencyId);
|
||
|
dependencyTask = tasks.find(t => t.id === depId);
|
||
|
|
||
|
// If task not found or has no dependencies, there's no circular dependency
|
||
|
if (!dependencyTask || !dependencyTask.dependencies || dependencyTask.dependencies.length === 0) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Recursively check each of the dependency's dependencies
|
||
|
const newChain = [...chainStrs, depIdStr];
|
||
|
if (dependencyTask.dependencies.some(depId => isCircularDependency(tasks, depId, newChain))) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// Also check for cycles through subtasks of this task
|
||
|
if (dependencyTask.subtasks && dependencyTask.subtasks.length > 0) {
|
||
|
for (const subtask of dependencyTask.subtasks) {
|
||
|
if (subtask.dependencies && subtask.dependencies.length > 0) {
|
||
|
// Check if any of this subtask's dependencies create a cycle
|
||
|
const subtaskId = `${dependencyTask.id}.${subtask.id}`;
|
||
|
const newSubtaskChain = [...chainStrs, depIdStr, subtaskId];
|
||
|
|
||
|
for (const subDepId of subtask.dependencies) {
|
||
|
// Handle relative subtask references
|
||
|
const normalizedDepId = typeof subDepId === 'number' && subDepId < 100
|
||
|
? `${dependencyTask.id}.${subDepId}`
|
||
|
: subDepId;
|
||
|
|
||
|
if (isCircularDependency(tasks, normalizedDepId, newSubtaskChain)) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Validate and clean up task dependencies to ensure they only reference existing tasks
|
||
|
* @param {Array} tasks - Array of tasks to validate
|
||
|
* @param {string} tasksPath - Optional path to tasks.json to save changes
|
||
|
* @returns {boolean} - True if any changes were made to dependencies
|
||
|
*/
|
||
|
function validateTaskDependencies(tasks, tasksPath = null) {
|
||
|
// Create a set of valid task IDs for fast lookup
|
||
|
const validTaskIds = new Set(tasks.map(t => t.id));
|
||
|
|
||
|
// Create a set of valid subtask IDs (in the format "parentId.subtaskId")
|
||
|
const validSubtaskIds = new Set();
|
||
|
tasks.forEach(task => {
|
||
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
||
|
task.subtasks.forEach(subtask => {
|
||
|
validSubtaskIds.add(`${task.id}.${subtask.id}`);
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Flag to track if any changes were made
|
||
|
let changesDetected = false;
|
||
|
|
||
|
// Validate all tasks and their dependencies
|
||
|
tasks.forEach(task => {
|
||
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
||
|
// First check for and remove duplicate dependencies
|
||
|
const uniqueDeps = new Set();
|
||
|
const uniqueDependencies = task.dependencies.filter(depId => {
|
||
|
// Convert to string for comparison to handle both numeric and string IDs
|
||
|
const depIdStr = String(depId);
|
||
|
if (uniqueDeps.has(depIdStr)) {
|
||
|
log('warn', `Removing duplicate dependency from task ${task.id}: ${depId}`);
|
||
|
changesDetected = true;
|
||
|
return false;
|
||
|
}
|
||
|
uniqueDeps.add(depIdStr);
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
// If we removed duplicates, update the array
|
||
|
if (uniqueDependencies.length !== task.dependencies.length) {
|
||
|
task.dependencies = uniqueDependencies;
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
|
||
|
const validDependencies = uniqueDependencies.filter(depId => {
|
||
|
const isSubtask = typeof depId === 'string' && depId.includes('.');
|
||
|
|
||
|
if (isSubtask) {
|
||
|
// Check if the subtask exists
|
||
|
if (!validSubtaskIds.has(depId)) {
|
||
|
log('warn', `Removing invalid subtask dependency from task ${task.id}: ${depId} (subtask does not exist)`);
|
||
|
return false;
|
||
|
}
|
||
|
return true;
|
||
|
} else {
|
||
|
// Check if the task exists
|
||
|
const numericId = typeof depId === 'string' ? parseInt(depId, 10) : depId;
|
||
|
if (!validTaskIds.has(numericId)) {
|
||
|
log('warn', `Removing invalid task dependency from task ${task.id}: ${depId} (task does not exist)`);
|
||
|
return false;
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Update the task's dependencies array
|
||
|
if (validDependencies.length !== uniqueDependencies.length) {
|
||
|
task.dependencies = validDependencies;
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Validate subtask dependencies
|
||
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
||
|
task.subtasks.forEach(subtask => {
|
||
|
if (subtask.dependencies && Array.isArray(subtask.dependencies)) {
|
||
|
// First check for and remove duplicate dependencies
|
||
|
const uniqueDeps = new Set();
|
||
|
const uniqueDependencies = subtask.dependencies.filter(depId => {
|
||
|
// Convert to string for comparison to handle both numeric and string IDs
|
||
|
const depIdStr = String(depId);
|
||
|
if (uniqueDeps.has(depIdStr)) {
|
||
|
log('warn', `Removing duplicate dependency from subtask ${task.id}.${subtask.id}: ${depId}`);
|
||
|
changesDetected = true;
|
||
|
return false;
|
||
|
}
|
||
|
uniqueDeps.add(depIdStr);
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
// If we removed duplicates, update the array
|
||
|
if (uniqueDependencies.length !== subtask.dependencies.length) {
|
||
|
subtask.dependencies = uniqueDependencies;
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
|
||
|
// Check for and remove self-dependencies
|
||
|
const subtaskId = `${task.id}.${subtask.id}`;
|
||
|
const selfDependencyIndex = subtask.dependencies.findIndex(depId => {
|
||
|
return String(depId) === String(subtaskId);
|
||
|
});
|
||
|
|
||
|
if (selfDependencyIndex !== -1) {
|
||
|
log('warn', `Removing self-dependency from subtask ${subtaskId} (subtask cannot depend on itself)`);
|
||
|
subtask.dependencies.splice(selfDependencyIndex, 1);
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
|
||
|
// Then validate remaining dependencies
|
||
|
const validSubtaskDeps = subtask.dependencies.filter(depId => {
|
||
|
const isSubtask = typeof depId === 'string' && depId.includes('.');
|
||
|
|
||
|
if (isSubtask) {
|
||
|
// Check if the subtask exists
|
||
|
if (!validSubtaskIds.has(depId)) {
|
||
|
log('warn', `Removing invalid subtask dependency from subtask ${task.id}.${subtask.id}: ${depId} (subtask does not exist)`);
|
||
|
return false;
|
||
|
}
|
||
|
return true;
|
||
|
} else {
|
||
|
// Check if the task exists
|
||
|
const numericId = typeof depId === 'string' ? parseInt(depId, 10) : depId;
|
||
|
if (!validTaskIds.has(numericId)) {
|
||
|
log('warn', `Removing invalid task dependency from task ${task.id}: ${depId} (task does not exist)`);
|
||
|
return false;
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Update the subtask's dependencies array
|
||
|
if (validSubtaskDeps.length !== subtask.dependencies.length) {
|
||
|
subtask.dependencies = validSubtaskDeps;
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Save changes if tasksPath is provided and changes were detected
|
||
|
if (tasksPath && changesDetected) {
|
||
|
try {
|
||
|
const data = readJSON(tasksPath);
|
||
|
if (data) {
|
||
|
data.tasks = tasks;
|
||
|
writeJSON(tasksPath, data);
|
||
|
log('info', 'Updated tasks.json to remove invalid and duplicate dependencies');
|
||
|
}
|
||
|
} catch (error) {
|
||
|
log('error', 'Failed to save changes to tasks.json', error);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return changesDetected;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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
|
||
|
};
|
||
|
|
||
|
// Monkey patch the log function to capture warnings and count fixes
|
||
|
const originalLog = log;
|
||
|
const warnings = [];
|
||
|
log = 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 originalLog(level, ...args);
|
||
|
};
|
||
|
|
||
|
// Run validation
|
||
|
try {
|
||
|
const changesDetected = validateTaskDependencies(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 } }
|
||
|
));
|
||
|
}
|
||
|
} finally {
|
||
|
// Restore the original log function
|
||
|
log = originalLog;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Clean up subtask dependencies by removing references to non-existent subtasks/tasks
|
||
|
* @param {Object} tasksData - The tasks data object with tasks array
|
||
|
* @returns {boolean} - True if any changes were made
|
||
|
*/
|
||
|
function cleanupSubtaskDependencies(tasksData) {
|
||
|
if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
log('debug', 'Cleaning up subtask dependencies...');
|
||
|
|
||
|
let changesDetected = false;
|
||
|
let duplicatesRemoved = 0;
|
||
|
|
||
|
// Create validity maps for fast lookup
|
||
|
const validTaskIds = new Set(tasksData.tasks.map(t => t.id));
|
||
|
const validSubtaskIds = new Set();
|
||
|
|
||
|
// Create a dependency map for cycle detection
|
||
|
const subtaskDependencyMap = new Map();
|
||
|
|
||
|
// Populate the validSubtaskIds set
|
||
|
tasksData.tasks.forEach(task => {
|
||
|
if (task.subtasks && Array.isArray(task.subtasks)) {
|
||
|
task.subtasks.forEach(subtask => {
|
||
|
validSubtaskIds.add(`${task.id}.${subtask.id}`);
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Clean up each task's subtasks
|
||
|
tasksData.tasks.forEach(task => {
|
||
|
if (!task.subtasks || !Array.isArray(task.subtasks)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
task.subtasks.forEach(subtask => {
|
||
|
if (!subtask.dependencies || !Array.isArray(subtask.dependencies)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const originalLength = subtask.dependencies.length;
|
||
|
const subtaskId = `${task.id}.${subtask.id}`;
|
||
|
|
||
|
// First remove duplicate dependencies
|
||
|
const uniqueDeps = new Set();
|
||
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
||
|
// Convert to string for comparison, handling special case for subtask references
|
||
|
let depIdStr = String(depId);
|
||
|
|
||
|
// For numeric IDs that are likely subtask references in the same parent task
|
||
|
if (typeof depId === 'number' && depId < 100) {
|
||
|
depIdStr = `${task.id}.${depId}`;
|
||
|
}
|
||
|
|
||
|
if (uniqueDeps.has(depIdStr)) {
|
||
|
log('debug', `Removing duplicate dependency from subtask ${subtaskId}: ${depId}`);
|
||
|
duplicatesRemoved++;
|
||
|
return false;
|
||
|
}
|
||
|
uniqueDeps.add(depIdStr);
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
// Then filter invalid dependencies
|
||
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
||
|
// Handle string dependencies with dot notation
|
||
|
if (typeof depId === 'string' && depId.includes('.')) {
|
||
|
if (!validSubtaskIds.has(depId)) {
|
||
|
log('debug', `Removing invalid subtask dependency from ${subtaskId}: ${depId}`);
|
||
|
return false;
|
||
|
}
|
||
|
if (depId === subtaskId) {
|
||
|
log('debug', `Removing self-dependency from ${subtaskId}`);
|
||
|
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 (fullSubtaskId === subtaskId) {
|
||
|
log('debug', `Removing self-dependency from ${subtaskId}`);
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (!validSubtaskIds.has(fullSubtaskId)) {
|
||
|
log('debug', `Removing invalid subtask dependency from ${subtaskId}: ${numericId}`);
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// Otherwise it's a task reference
|
||
|
if (!validTaskIds.has(numericId)) {
|
||
|
log('debug', `Removing invalid task dependency from ${subtaskId}: ${numericId}`);
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
if (subtask.dependencies.length < originalLength) {
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
|
||
|
// Build dependency map for cycle detection
|
||
|
subtaskDependencyMap.set(subtaskId, 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);
|
||
|
}));
|
||
|
});
|
||
|
});
|
||
|
|
||
|
// Break circular dependencies in subtasks
|
||
|
tasksData.tasks.forEach(task => {
|
||
|
if (!task.subtasks || !Array.isArray(task.subtasks)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
task.subtasks.forEach(subtask => {
|
||
|
const subtaskId = `${task.id}.${subtask.id}`;
|
||
|
|
||
|
// Skip if no dependencies
|
||
|
if (!subtask.dependencies || !Array.isArray(subtask.dependencies) || subtask.dependencies.length === 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Detect cycles for this subtask
|
||
|
const visited = new Set();
|
||
|
const recursionStack = new Set();
|
||
|
const cyclesToBreak = findCycles(subtaskId, subtaskDependencyMap, visited, recursionStack);
|
||
|
|
||
|
if (cyclesToBreak.length > 0) {
|
||
|
const originalLength = subtask.dependencies.length;
|
||
|
|
||
|
// Format cycle paths for removal
|
||
|
const edgesToRemove = cyclesToBreak.map(edge => {
|
||
|
if (edge.includes('.')) {
|
||
|
const [depTaskId, depSubtaskId] = edge.split('.').map(Number);
|
||
|
if (depTaskId === task.id) {
|
||
|
return depSubtaskId; // Return just subtask ID if in the same task
|
||
|
}
|
||
|
return edge; // Full subtask ID string
|
||
|
}
|
||
|
return Number(edge); // Task ID
|
||
|
});
|
||
|
|
||
|
// Remove dependencies that cause cycles
|
||
|
subtask.dependencies = subtask.dependencies.filter(depId => {
|
||
|
const normalizedDepId = typeof depId === 'number' && depId < 100
|
||
|
? `${task.id}.${depId}`
|
||
|
: String(depId);
|
||
|
|
||
|
if (edgesToRemove.includes(depId) || edgesToRemove.includes(normalizedDepId)) {
|
||
|
log('debug', `Breaking circular dependency: Removing ${normalizedDepId} from ${subtaskId}`);
|
||
|
return false;
|
||
|
}
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
if (subtask.dependencies.length < originalLength) {
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
|
||
|
if (changesDetected) {
|
||
|
log('debug', `Cleaned up subtask dependencies (removed ${duplicatesRemoved} duplicates and fixed circular references)`);
|
||
|
}
|
||
|
|
||
|
return changesDetected;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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;
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Remove duplicate dependencies from tasks and subtasks
|
||
|
* @param {Object} tasksData - The tasks data object with tasks array
|
||
|
* @returns {boolean} - True if any changes were made
|
||
|
*/
|
||
|
function removeDuplicateDependencies(tasksData) {
|
||
|
if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
let changesDetected = false;
|
||
|
|
||
|
tasksData.tasks.forEach(task => {
|
||
|
// Remove duplicates from main task dependencies
|
||
|
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('debug', `Removing duplicate dependency from task ${task.id}: ${depId}`);
|
||
|
return false;
|
||
|
}
|
||
|
uniqueDeps.add(depIdStr);
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
if (task.dependencies.length < originalLength) {
|
||
|
changesDetected = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Remove duplicates from subtask dependencies
|
||
|
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 => {
|
||
|
// Convert to string for comparison, handling special case for subtask references
|
||
|
let depIdStr = String(depId);
|
||
|
|
||
|
// For numeric IDs that are likely subtask references in the same parent task
|
||
|
if (typeof depId === 'number' && depId < 100) {
|
||
|
depIdStr = `${task.id}.${depId}`;
|
||
|
}
|
||
|
|
||
|
if (uniqueDeps.has(depIdStr)) {
|
||
|
log('debug', `Removing duplicate dependency from subtask ${task.id}.${subtask.id}: ${depId}`);
|
||
|
return false;
|
||
|
}
|
||
|
uniqueDeps.add(depIdStr);
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
if (subtask.dependencies.length < originalLength) {
|
||
|
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...');
|
||
|
|
||
|
let changesDetected = false;
|
||
|
|
||
|
// 1. Remove duplicate dependencies from tasks and subtasks
|
||
|
const hasDuplicates = removeDuplicateDependencies(tasksData);
|
||
|
if (hasDuplicates) changesDetected = true;
|
||
|
|
||
|
// 2. Remove invalid task dependencies (non-existent tasks)
|
||
|
const validationChanges = validateTaskDependencies(tasksData.tasks);
|
||
|
if (validationChanges) changesDetected = true;
|
||
|
|
||
|
// 3. Clean up subtask dependencies
|
||
|
const subtaskChanges = cleanupSubtaskDependencies(tasksData);
|
||
|
if (subtaskChanges) changesDetected = true;
|
||
|
|
||
|
// 4. Ensure at least one subtask has no dependencies in each task
|
||
|
const noDepChanges = ensureAtLeastOneIndependentSubtask(tasksData);
|
||
|
if (noDepChanges) changesDetected = true;
|
||
|
|
||
|
// 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,
|
||
|
validateTaskDependencies,
|
||
|
validateDependenciesCommand,
|
||
|
fixDependenciesCommand,
|
||
|
cleanupSubtaskDependencies,
|
||
|
ensureAtLeastOneIndependentSubtask,
|
||
|
validateAndFixDependencies
|
||
|
}
|