Parthy fd005c4c54 fix(core): Implement Boundary-First Tag Resolution (#943)
* refactor(context): Standardize tag and projectRoot handling across all task tools

This commit unifies context management by adopting a boundary-first resolution strategy. All task-scoped tools now resolve `tag` and `projectRoot` at their entry point and forward these values to the underlying direct functions.

This approach centralizes context logic, ensuring consistent behavior and enhanced flexibility in multi-tag environments.

* fix(tag): Clean up tag handling in task functions and sync process

This commit refines the handling of the `tag` parameter across multiple functions, ensuring consistent context management. The `tag` is now passed more efficiently in `listTasksDirect`, `setTaskStatusDirect`, and `syncTasksToReadme`, improving clarity and reducing redundancy. Additionally, a TODO comment has been added in `sync-readme.js` to address future tag support enhancements.

* feat(tag): Implement Boundary-First Tag Resolution for consistent tag handling

This commit introduces Boundary-First Tag Resolution in the task manager, ensuring consistent and deterministic tag handling across CLI and MCP. This change resolves potential race conditions and improves the reliability of tag-specific operations.

Additionally, the `expandTask` function has been updated to use the resolved tag when writing JSON, enhancing data integrity during task updates.

* chore(biome): formatting

* fix(expand-task): Update writeJSON call to use tag instead of resolvedTag

* fix(commands): Enhance complexity report path resolution and task initialization
`resolveComplexityReportPath` function to streamline output path generation based on tag context and user-defined output.
- Improved clarity and maintainability of command handling by centralizing path resolution logic.

* Fix: unknown currentTag

* fix(task-manager): Update generateTaskFiles calls to include tag and projectRoot parameters

This commit modifies the `moveTask` and `updateSubtaskById` functions to pass the `tag` and `projectRoot` parameters to the `generateTaskFiles` function. This ensures that task files are generated with the correct context when requested, enhancing consistency in task management operations.

* fix(commands): Refactor tag handling and complexity report path resolution
This commit updates the `registerCommands` function to utilize `taskMaster.getCurrentTag()` for consistent tag retrieval across command actions. It also enhances the initialization of `TaskMaster` by passing the tag directly, improving clarity and maintainability. The complexity report path resolution is streamlined to ensure correct file naming based on the current tag context.

* fix(task-master): Update complexity report path expectations in tests
This commit modifies the `initTaskMaster` test to expect a valid string for the complexity report path, ensuring it matches the expected file naming convention. This change enhances test reliability by verifying the correct output format when the path is generated.

* fix(set-task-status): Enhance logging and tag resolution in task status updates
This commit improves the logging output in the `registerSetTaskStatusTool` function to include the tag context when setting task statuses. It also updates the tag handling by resolving the tag using the `resolveTag` utility, ensuring that the correct tag is used when updating task statuses. Additionally, the `setTaskStatus` function is modified to remove the tag parameter from the `readJSON` and `writeJSON` calls, streamlining the data handling process.

* fix(commands, expand-task, task-manager): Add complexity report option and enhance path handling
This commit introduces a new `--complexity-report` option in the `registerCommands` function, allowing users to specify a custom path for the complexity report. The `expandTask` function is updated to accept the `complexityReportPath` from the context, ensuring it is utilized correctly during task expansion. Additionally, the `setTaskStatus` function now includes the `tag` parameter in the `readJSON` and `writeJSON` calls, improving task status updates with proper context. The `initTaskMaster` function is also modified to create parent directories for output paths, enhancing file handling robustness.

* fix(expand-task): Add complexityReportPath to context for task expansion tests

This commit updates the test for the `expandTask` function by adding the `complexityReportPath` to the context object. This change ensures that the complexity report path is correctly utilized in the test, aligning with recent enhancements to complexity report handling in the task manager.

* chore: implement suggested changes

* fix(parse-prd): Clarify tag parameter description for task organization
Updated the documentation for the `tag` parameter in the `parse-prd.js` file to provide a clearer context on its purpose for organizing tasks into separate task lists.

* Fix Inconsistent tag resolution pattern.

* fix: Enhance complexity report path handling with tag support

This commit updates various functions to incorporate the `tag` parameter when resolving complexity report paths. The `expandTaskDirect`, `resolveComplexityReportPath`, and related tools now utilize the current tag context, improving consistency in task management. Additionally, the complexity report path is now correctly passed through the context in the `expand-task` and `set-task-status` tools, ensuring accurate report retrieval based on the active tag.

* Updated the JSDoc for the `tag` parameter in the `show-task.js` file.

* Remove redundant comment on tag parameter in readJSON call

* Remove unused import for getTagAwareFilePath

* Add missed complexityReportPath to args for task expansion

* fix(tests): Enhance research tests with tag-aware functionality

This commit updates the `research.test.js` file to improve the testing of the `performResearch` function by incorporating tag-aware functionality. Key changes include mocking the `findProjectRoot` to return a valid path, enhancing the `ContextGatherer` and `FuzzyTaskSearch` mocks, and adding comprehensive tests for tag parameter handling in various scenarios. The tests now cover passing different tag values, ensuring correct behavior when tags are provided, undefined, or null, and validating the integration of tags in task discovery and context gathering processes.

* Remove unused import for

* fix: Refactor complexity report path handling and improve argument destructuring

This commit enhances the `expandTaskDirect` function by improving the destructuring of arguments for better readability. It also updates the `analyze.js` and `analyze-task-complexity.js` files to utilize the new `resolveComplexityReportOutputPath` function, ensuring tag-aware resolution of output paths. Additionally, logging has been added to provide clarity on the report path being used.

* test: Add complexity report tag isolation tests and improve path handling

This commit introduces a new test file for complexity report tag isolation, ensuring that different tags maintain separate complexity reports. It enhances the existing tests in `analyze-task-complexity.test.js` by updating expectations to use `expect.stringContaining` for file paths, improving robustness against path changes. The new tests cover various scenarios, including path resolution and report generation for both master and feature tags, ensuring no cross-tag contamination occurs.

* Update scripts/modules/task-manager/list-tasks.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update scripts/modules/task-manager/list-tasks.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test(complexity-report): Fix tag slugification in filename expectations

- Update mocks to use slugifyTagForFilePath for cross-platform compatibility
- Replace raw tag values with slugified versions in expected filenames
- Fix test expecting 'feature/user-auth-v2' to expect 'feature-user-auth-v2'
- Align test with actual filename generation logic that sanitizes special chars

---------

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-20 00:51:41 +03:00

482 lines
14 KiB
JavaScript

import path from 'path';
import { log, readJSON, writeJSON, setTasksForTag } from '../utils.js';
import { isTaskDependentOn } from '../task-manager.js';
import generateTaskFiles from './generate-task-files.js';
/**
* Move one or more tasks/subtasks to new positions
* @param {string} tasksPath - Path to tasks.json file
* @param {string} sourceId - ID(s) of the task/subtask to move (e.g., '5' or '5.2' or '5,6,7')
* @param {string} destinationId - ID(s) of the destination (e.g., '7' or '7.3' or '7,8,9')
* @param {boolean} generateFiles - Whether to regenerate task files after moving
* @param {Object} options - Additional options
* @param {string} options.projectRoot - Project root directory for tag resolution
* @param {string} options.tag - Explicit tag to use (optional)
* @returns {Object} Result object with moved task details
*/
async function moveTask(
tasksPath,
sourceId,
destinationId,
generateFiles = false,
options = {}
) {
const { projectRoot, tag } = options;
// Check if we have comma-separated IDs (batch move)
const sourceIds = sourceId.split(',').map((id) => id.trim());
const destinationIds = destinationId.split(',').map((id) => id.trim());
if (sourceIds.length !== destinationIds.length) {
throw new Error(
`Number of source IDs (${sourceIds.length}) must match number of destination IDs (${destinationIds.length})`
);
}
// For batch moves, process each pair sequentially
if (sourceIds.length > 1) {
const results = [];
for (let i = 0; i < sourceIds.length; i++) {
const result = await moveTask(
tasksPath,
sourceIds[i],
destinationIds[i],
false, // Don't generate files for each individual move
options
);
results.push(result);
}
// Generate files once at the end if requested
if (generateFiles) {
await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
tag: tag,
projectRoot: projectRoot
});
}
return {
message: `Successfully moved ${sourceIds.length} tasks/subtasks`,
moves: results
};
}
// Single move logic
// Read the raw data without tag resolution to preserve tagged structure
let rawData = readJSON(tasksPath, projectRoot, tag);
// Handle the case where readJSON returns resolved data with _rawTaggedData
if (rawData && rawData._rawTaggedData) {
// Use the raw tagged data and discard the resolved view
rawData = rawData._rawTaggedData;
}
// Ensure the tag exists in the raw data
if (!rawData || !rawData[tag] || !Array.isArray(rawData[tag].tasks)) {
throw new Error(
`Invalid tasks file or tag "${tag}" not found at ${tasksPath}`
);
}
// Get the tasks for the current tag
const tasks = rawData[tag].tasks;
log(
'info',
`Moving task/subtask ${sourceId} to ${destinationId} (tag: ${tag})`
);
// Parse source and destination IDs
const isSourceSubtask = sourceId.includes('.');
const isDestSubtask = destinationId.includes('.');
let result;
if (isSourceSubtask && isDestSubtask) {
// Subtask to subtask
result = moveSubtaskToSubtask(tasks, sourceId, destinationId);
} else if (isSourceSubtask && !isDestSubtask) {
// Subtask to task
result = moveSubtaskToTask(tasks, sourceId, destinationId);
} else if (!isSourceSubtask && isDestSubtask) {
// Task to subtask
result = moveTaskToSubtask(tasks, sourceId, destinationId);
} else {
// Task to task
result = moveTaskToTask(tasks, sourceId, destinationId);
}
// Update the data structure with the modified tasks
rawData[tag].tasks = tasks;
// Always write the data object, never the _rawTaggedData directly
// The writeJSON function will filter out _rawTaggedData automatically
writeJSON(tasksPath, rawData, options.projectRoot, tag);
if (generateFiles) {
await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
tag: tag,
projectRoot: projectRoot
});
}
return result;
}
// Helper functions for different move scenarios
function moveSubtaskToSubtask(tasks, sourceId, destinationId) {
// Parse IDs
const [sourceParentId, sourceSubtaskId] = sourceId
.split('.')
.map((id) => parseInt(id, 10));
const [destParentId, destSubtaskId] = destinationId
.split('.')
.map((id) => parseInt(id, 10));
// Find source and destination parent tasks
const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
const destParentTask = tasks.find((t) => t.id === destParentId);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${sourceParentId} not found`);
}
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${destParentId} not found`
);
}
// Initialize subtasks arrays if they don't exist (based on commit fixes)
if (!sourceParentTask.subtasks) {
sourceParentTask.subtasks = [];
}
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Find source subtask
const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === sourceSubtaskId
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
if (sourceParentId === destParentId) {
// Moving within the same parent
if (destParentTask.subtasks.length > 0) {
const destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId
);
if (destSubtaskIndex !== -1) {
// Remove from old position
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// Insert at new position (adjust index if moving within same array)
const adjustedIndex =
sourceSubtaskIndex < destSubtaskIndex
? destSubtaskIndex - 1
: destSubtaskIndex;
destParentTask.subtasks.splice(adjustedIndex + 1, 0, sourceSubtask);
} else {
// Destination subtask doesn't exist, insert at end
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
destParentTask.subtasks.push(sourceSubtask);
}
} else {
// No existing subtasks, this will be the first one
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
destParentTask.subtasks.push(sourceSubtask);
}
} else {
// Moving between different parents
moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskId
);
}
return {
message: `Moved subtask ${sourceId} to ${destinationId}`,
movedItem: sourceSubtask
};
}
function moveSubtaskToTask(tasks, sourceId, destinationId) {
// Parse source ID
const [sourceParentId, sourceSubtaskId] = sourceId
.split('.')
.map((id) => parseInt(id, 10));
const destTaskId = parseInt(destinationId, 10);
// Find source parent and destination task
const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${sourceParentId} not found`);
}
if (!sourceParentTask.subtasks) {
throw new Error(`Source parent task ${sourceParentId} has no subtasks`);
}
// Find source subtask
const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === sourceSubtaskId
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
// Check if destination task exists
const existingDestTask = tasks.find((t) => t.id === destTaskId);
if (existingDestTask) {
throw new Error(
`Cannot move to existing task ID ${destTaskId}. Choose a different ID or use subtask destination.`
);
}
// Create new task from subtask
const newTask = {
id: destTaskId,
title: sourceSubtask.title,
description: sourceSubtask.description,
status: sourceSubtask.status || 'pending',
dependencies: sourceSubtask.dependencies || [],
priority: sourceSubtask.priority || 'medium',
details: sourceSubtask.details || '',
testStrategy: sourceSubtask.testStrategy || '',
subtasks: []
};
// Remove subtask from source parent
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// Insert new task in correct position
const insertIndex = tasks.findIndex((t) => t.id > destTaskId);
if (insertIndex === -1) {
tasks.push(newTask);
} else {
tasks.splice(insertIndex, 0, newTask);
}
return {
message: `Converted subtask ${sourceId} to task ${destinationId}`,
movedItem: newTask
};
}
function moveTaskToSubtask(tasks, sourceId, destinationId) {
// Parse IDs
const sourceTaskId = parseInt(sourceId, 10);
const [destParentId, destSubtaskId] = destinationId
.split('.')
.map((id) => parseInt(id, 10));
// Find source task and destination parent
const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
const destParentTask = tasks.find((t) => t.id === destParentId);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceTaskId} not found`);
}
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${destParentId} not found`
);
}
const sourceTask = tasks[sourceTaskIndex];
// Initialize subtasks array if it doesn't exist (based on commit fixes)
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Create new subtask from task
const newSubtask = {
id: destSubtaskId,
title: sourceTask.title,
description: sourceTask.description,
status: sourceTask.status || 'pending',
dependencies: sourceTask.dependencies || [],
details: sourceTask.details || '',
testStrategy: sourceTask.testStrategy || ''
};
// Find insertion position (based on commit fixes)
let destSubtaskIndex = -1;
if (destParentTask.subtasks.length > 0) {
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId
);
if (destSubtaskIndex === -1) {
// Subtask doesn't exist, we'll insert at the end
destSubtaskIndex = destParentTask.subtasks.length - 1;
}
}
// Insert at specific position (based on commit fixes)
const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
// Remove the original task from the tasks array
tasks.splice(sourceTaskIndex, 1);
return {
message: `Converted task ${sourceId} to subtask ${destinationId}`,
movedItem: newSubtask
};
}
function moveTaskToTask(tasks, sourceId, destinationId) {
const sourceTaskId = parseInt(sourceId, 10);
const destTaskId = parseInt(destinationId, 10);
// Find source task
const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceTaskId} not found`);
}
const sourceTask = tasks[sourceTaskIndex];
// Check if destination exists
const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
if (destTaskIndex !== -1) {
// Destination exists - this could be overwriting or swapping
const destTask = tasks[destTaskIndex];
// For now, throw an error to avoid accidental overwrites
throw new Error(
`Task with ID ${destTaskId} already exists. Use a different destination ID.`
);
} else {
// Destination doesn't exist - create new task ID
return moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId);
}
}
function moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskId
) {
const destSubtaskId_num = parseInt(destSubtaskId, 10);
// Create new subtask with destination ID
const newSubtask = {
...sourceSubtask,
id: destSubtaskId_num
};
// Initialize subtasks array if it doesn't exist (based on commit fixes)
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Find insertion position
let destSubtaskIndex = -1;
if (destParentTask.subtasks.length > 0) {
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId_num
);
if (destSubtaskIndex === -1) {
// Subtask doesn't exist, we'll insert at the end
destSubtaskIndex = destParentTask.subtasks.length - 1;
}
}
// Insert at the destination position (based on commit fixes)
const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
// Remove the subtask from the original parent
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
return newSubtask;
}
function moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId) {
const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
// Create moved task with new ID
const movedTask = {
...sourceTask,
id: destTaskId
};
// Update any dependencies that reference the old task ID
tasks.forEach((task) => {
if (task.dependencies && task.dependencies.includes(sourceTask.id)) {
const depIndex = task.dependencies.indexOf(sourceTask.id);
task.dependencies[depIndex] = destTaskId;
}
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (
subtask.dependencies &&
subtask.dependencies.includes(sourceTask.id)
) {
const depIndex = subtask.dependencies.indexOf(sourceTask.id);
subtask.dependencies[depIndex] = destTaskId;
}
});
}
});
// Update dependencies within movedTask's subtasks that reference sibling subtasks
if (Array.isArray(movedTask.subtasks)) {
movedTask.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
// If dependency is a string like "oldParent.subId", update to "newParent.subId"
if (typeof dep === 'string' && dep.includes('.')) {
const [depParent, depSub] = dep.split('.');
if (parseInt(depParent, 10) === sourceTask.id) {
return `${destTaskId}.${depSub}`;
}
}
// If dependency is a number, and matches a subtask ID in the moved task, leave as is (context is implied)
return dep;
});
}
});
}
// Strategy based on commit fixes: remove source first, then replace destination
// This avoids index shifting problems
// Remove the source task first
tasks.splice(sourceTaskIndex, 1);
// Adjust the destination index if the source was before the destination
// Since we removed the source, indices after it shift down by 1
const adjustedDestIndex =
sourceTaskIndex < destTaskIndex ? destTaskIndex - 1 : destTaskIndex;
// Replace the placeholder destination task with the moved task (based on commit fixes)
if (adjustedDestIndex >= 0 && adjustedDestIndex < tasks.length) {
tasks[adjustedDestIndex] = movedTask;
} else {
// Insert at the end if index is out of bounds
tasks.push(movedTask);
}
log('info', `Moved task ${sourceTask.id} to new ID ${destTaskId}`);
return {
message: `Moved task ${sourceTask.id} to new ID ${destTaskId}`,
movedItem: movedTask
};
}
export default moveTask;