fix: Critical writeJSON Context Fixes - Prevent Tag Corruption (#910)

* feat(tasks): Fix critical tag corruption bug in task management

- Fixed missing context parameters in writeJSON calls across add-task, remove-task, and add-subtask functions
- Added projectRoot and tag parameters to prevent data corruption in multi-tag environments
- Re-enabled generateTaskFiles calls to ensure markdown files are updated after operations
- Enhanced add_subtask MCP tool with tag parameter support
- Refactored addSubtaskDirect function to properly pass context to core logic
- Streamlined codebase by removing deprecated functionality

This resolves the critical bug where task operations in one tag context would corrupt or delete tasks from other tags in tasks.json.

* feat(task-manager): Enhance addSubtask with current tag support

- Added `getCurrentTag` utility to retrieve the current tag context for task operations.
- Updated `addSubtask` to use the current tag when reading and writing tasks, ensuring proper context handling.
- Refactored tests to accommodate changes in the `addSubtask` function, ensuring accurate mock implementations and expectations.
- Cleaned up test cases for better readability and maintainability.

This improves task management by preventing tag-related data corruption and enhances the overall functionality of the task manager.

* feat(remove-task): Add tag support for task removal and enhance error handling

- Introduced `tag` parameter in `removeTaskDirect` to specify context for task operations, improving multi-tag support.
- Updated logging to include tag context in messages for better traceability.
- Refactored task removal logic to streamline the process and improve error reporting.
- Added comprehensive unit tests to validate tag handling and ensure robust error management.

This enhancement prevents task data corruption across different tags and improves the overall reliability of the task management system.

* feat(add-task): Add projectRoot and tag parameters to addTask tests

- Updated `addTask` unit tests to include `projectRoot` and `tag` parameters for better context handling.
- Enhanced test cases to ensure accurate expectations and improve overall test coverage.

This change aligns with recent enhancements in task management, ensuring consistency across task operations.

* feat(set-task-status): Add tag parameter support and enhance task status handling

- Introduced `tag` parameter in `setTaskStatusDirect` and related functions to improve context management in multi-tag environments.
- Updated `writeJSON` calls to ensure task data integrity across different tags.
- Enhanced unit tests to validate tag preservation during task status updates, ensuring robust functionality.

This change aligns with recent improvements in task management, preventing data corruption and enhancing overall reliability.

* feat(tag-management): Enhance writeJSON calls to preserve tag context

- Updated `writeJSON` calls in `createTag`, `deleteTag`, `renameTag`, `copyTag`, and `enhanceTagsWithMetadata` to include `projectRoot` for better context management and to prevent tag corruption.
- Added comprehensive unit tests for tag management functions to ensure data integrity and proper tag handling during operations.

This change improves the reliability of tag management by ensuring that operations do not corrupt existing tags and maintains the overall structure of the task data.

* feat(expand-task): Update writeJSON to include projectRoot and tag context

- Modified `writeJSON` call in `expandTaskDirect` to pass `projectRoot` and `tag` parameters, ensuring proper context management when saving tasks.json.
- This change aligns with recent enhancements in task management, preventing potential data corruption and improving overall reliability.

* feat(fix-dependencies): Add projectRoot and tag parameters for enhanced context management

- Updated `fixDependenciesDirect` and `registerFixDependenciesTool` to include `projectRoot` and `tag` parameters, improving context handling during dependency fixes.
- Introduced a new unit test for `fixDependenciesCommand` to ensure proper preservation of projectRoot and tag data in JSON outputs.

This change enhances the reliability of dependency management by ensuring that context is maintained across operations, preventing potential data issues.

* fix(context): propagate projectRoot and tag through dependency, expansion, status-update and tag-management commands to prevent cross-tag data corruption

* test(fix-dependencies): Enhance unit tests for fixDependenciesCommand

- Refactored tests to use unstable mocks for utils, ui, and task-manager modules, improving isolation and reliability.
- Added checks for process.exit to ensure proper handling of invalid data scenarios.
- Updated test cases to verify writeJSON calls with projectRoot and tag parameters, ensuring accurate context preservation during dependency fixes.

This change strengthens the test suite for dependency management, ensuring robust functionality and preventing potential data issues.

* chore(plan): remove outdated fix plan for `writeJSON` context parameters
This commit is contained in:
Parthy
2025-07-02 21:45:10 +02:00
committed by GitHub
parent 43e0025f4c
commit 2852149a47
22 changed files with 1166 additions and 389 deletions

View File

@@ -0,0 +1,528 @@
/**
* Tests for the remove-task MCP tool
*
* Note: This test does NOT test the actual implementation. It tests that:
* 1. The tool is registered correctly with the correct parameters
* 2. Arguments are passed correctly to removeTaskDirect
* 3. Error handling works as expected
* 4. Tag parameter is properly handled and passed through
*
* We do NOT import the real implementation - everything is mocked
*/
import { jest } from '@jest/globals';
// Mock EVERYTHING
const mockRemoveTaskDirect = jest.fn();
jest.mock('../../../../mcp-server/src/core/task-master-core.js', () => ({
removeTaskDirect: mockRemoveTaskDirect
}));
const mockHandleApiResult = jest.fn((result) => result);
const mockWithNormalizedProjectRoot = jest.fn((fn) => fn);
const mockCreateErrorResponse = jest.fn((msg) => ({
success: false,
error: { code: 'ERROR', message: msg }
}));
const mockFindTasksPath = jest.fn(() => '/mock/project/tasks.json');
jest.mock('../../../../mcp-server/src/tools/utils.js', () => ({
handleApiResult: mockHandleApiResult,
createErrorResponse: mockCreateErrorResponse,
withNormalizedProjectRoot: mockWithNormalizedProjectRoot
}));
jest.mock('../../../../mcp-server/src/core/utils/path-utils.js', () => ({
findTasksPath: mockFindTasksPath
}));
// Mock the z object from zod
const mockZod = {
object: jest.fn(() => mockZod),
string: jest.fn(() => mockZod),
boolean: jest.fn(() => mockZod),
optional: jest.fn(() => mockZod),
describe: jest.fn(() => mockZod),
_def: {
shape: () => ({
id: {},
file: {},
projectRoot: {},
confirm: {},
tag: {}
})
}
};
jest.mock('zod', () => ({
z: mockZod
}));
// DO NOT import the real module - create a fake implementation
// This is the fake implementation of registerRemoveTaskTool
const registerRemoveTaskTool = (server) => {
// Create simplified version of the tool config
const toolConfig = {
name: 'remove_task',
description: 'Remove a task or subtask permanently from the tasks list',
parameters: mockZod,
// Create a simplified mock of the execute function
execute: mockWithNormalizedProjectRoot(async (args, context) => {
const { log, session } = context;
try {
log.info && log.info(`Removing task(s) with ID(s): ${args.id}`);
// Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot)
let tasksJsonPath;
try {
tasksJsonPath = mockFindTasksPath(
{ projectRoot: args.projectRoot, file: args.file },
log
);
} catch (error) {
log.error && log.error(`Error finding tasks.json: ${error.message}`);
return mockCreateErrorResponse(
`Failed to find tasks.json: ${error.message}`
);
}
log.info && log.info(`Using tasks file path: ${tasksJsonPath}`);
const result = await mockRemoveTaskDirect(
{
tasksJsonPath: tasksJsonPath,
id: args.id,
projectRoot: args.projectRoot,
tag: args.tag
},
log,
{ session }
);
if (result.success) {
log.info && log.info(`Successfully removed task: ${args.id}`);
} else {
log.error &&
log.error(`Failed to remove task: ${result.error.message}`);
}
return mockHandleApiResult(
result,
log,
'Error removing task',
undefined,
args.projectRoot
);
} catch (error) {
log.error && log.error(`Error in remove-task tool: ${error.message}`);
return mockCreateErrorResponse(error.message);
}
})
};
// Register the tool with the server
server.addTool(toolConfig);
};
describe('MCP Tool: remove-task', () => {
// Create mock server
let mockServer;
let executeFunction;
// Create mock logger
const mockLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
};
// Test data
const validArgs = {
id: '5',
projectRoot: '/mock/project/root',
file: '/mock/project/tasks.json',
confirm: true,
tag: 'feature-branch'
};
const multipleTaskArgs = {
id: '5,6.1,7',
projectRoot: '/mock/project/root',
tag: 'master'
};
// Standard responses
const successResponse = {
success: true,
data: {
totalTasks: 1,
successful: 1,
failed: 0,
removedTasks: [
{
id: 5,
title: 'Removed Task',
status: 'pending'
}
],
messages: ["Successfully removed task 5 from tag 'feature-branch'"],
errors: [],
tasksPath: '/mock/project/tasks.json',
tag: 'feature-branch'
}
};
const multipleTasksSuccessResponse = {
success: true,
data: {
totalTasks: 3,
successful: 3,
failed: 0,
removedTasks: [
{ id: 5, title: 'Task 5', status: 'pending' },
{ id: 1, title: 'Subtask 6.1', status: 'done', parentTaskId: 6 },
{ id: 7, title: 'Task 7', status: 'in-progress' }
],
messages: [
"Successfully removed task 5 from tag 'master'",
"Successfully removed subtask 6.1 from tag 'master'",
"Successfully removed task 7 from tag 'master'"
],
errors: [],
tasksPath: '/mock/project/tasks.json',
tag: 'master'
}
};
const errorResponse = {
success: false,
error: {
code: 'INVALID_TASK_ID',
message: "The following tasks were not found in tag 'feature-branch': 999"
}
};
const pathErrorResponse = {
success: false,
error: {
code: 'PATH_ERROR',
message: 'Failed to find tasks.json: No tasks.json found'
}
};
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Create mock server
mockServer = {
addTool: jest.fn((config) => {
executeFunction = config.execute;
})
};
// Setup default successful response
mockRemoveTaskDirect.mockResolvedValue(successResponse);
mockFindTasksPath.mockReturnValue('/mock/project/tasks.json');
// Register the tool
registerRemoveTaskTool(mockServer);
});
test('should register the tool correctly', () => {
// Verify tool was registered
expect(mockServer.addTool).toHaveBeenCalledWith(
expect.objectContaining({
name: 'remove_task',
description: 'Remove a task or subtask permanently from the tasks list',
parameters: expect.any(Object),
execute: expect.any(Function)
})
);
// Verify the tool config was passed
const toolConfig = mockServer.addTool.mock.calls[0][0];
expect(toolConfig).toHaveProperty('parameters');
expect(toolConfig).toHaveProperty('execute');
});
test('should execute the tool with valid parameters including tag', async () => {
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(validArgs, mockContext);
// Verify findTasksPath was called with correct arguments
expect(mockFindTasksPath).toHaveBeenCalledWith(
{
projectRoot: validArgs.projectRoot,
file: validArgs.file
},
mockLogger
);
// Verify removeTaskDirect was called with correct arguments including tag
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
expect.objectContaining({
tasksJsonPath: '/mock/project/tasks.json',
id: validArgs.id,
projectRoot: validArgs.projectRoot,
tag: validArgs.tag // This is the key test - tag parameter should be passed through
}),
mockLogger,
{
session: mockContext.session
}
);
// Verify handleApiResult was called
expect(mockHandleApiResult).toHaveBeenCalledWith(
successResponse,
mockLogger,
'Error removing task',
undefined,
validArgs.projectRoot
);
});
test('should handle multiple task IDs with tag context', async () => {
// Setup multiple tasks response
mockRemoveTaskDirect.mockResolvedValueOnce(multipleTasksSuccessResponse);
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(multipleTaskArgs, mockContext);
// Verify removeTaskDirect was called with comma-separated IDs and tag
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
expect.objectContaining({
id: '5,6.1,7',
tag: 'master'
}),
mockLogger,
expect.any(Object)
);
// Verify successful handling of multiple tasks
expect(mockHandleApiResult).toHaveBeenCalledWith(
multipleTasksSuccessResponse,
mockLogger,
'Error removing task',
undefined,
multipleTaskArgs.projectRoot
);
});
test('should handle missing tag parameter (defaults to current tag)', async () => {
const argsWithoutTag = {
id: '5',
projectRoot: '/mock/project/root'
};
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(argsWithoutTag, mockContext);
// Verify removeTaskDirect was called with undefined tag (should default to current tag)
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
expect.objectContaining({
id: '5',
projectRoot: '/mock/project/root',
tag: undefined // Should be undefined when not provided
}),
mockLogger,
expect.any(Object)
);
});
test('should handle errors from removeTaskDirect', async () => {
// Setup error response
mockRemoveTaskDirect.mockResolvedValueOnce(errorResponse);
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(validArgs, mockContext);
// Verify removeTaskDirect was called
expect(mockRemoveTaskDirect).toHaveBeenCalled();
// Verify error logging
expect(mockLogger.error).toHaveBeenCalledWith(
"Failed to remove task: The following tasks were not found in tag 'feature-branch': 999"
);
// Verify handleApiResult was called with error response
expect(mockHandleApiResult).toHaveBeenCalledWith(
errorResponse,
mockLogger,
'Error removing task',
undefined,
validArgs.projectRoot
);
});
test('should handle path finding errors', async () => {
// Setup path finding error
mockFindTasksPath.mockImplementationOnce(() => {
throw new Error('No tasks.json found');
});
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
const result = await executeFunction(validArgs, mockContext);
// Verify error logging
expect(mockLogger.error).toHaveBeenCalledWith(
'Error finding tasks.json: No tasks.json found'
);
// Verify error response was returned
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Failed to find tasks.json: No tasks.json found'
);
// Verify removeTaskDirect was NOT called
expect(mockRemoveTaskDirect).not.toHaveBeenCalled();
});
test('should handle unexpected errors in execute function', async () => {
// Setup unexpected error
mockRemoveTaskDirect.mockImplementationOnce(() => {
throw new Error('Unexpected error');
});
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(validArgs, mockContext);
// Verify error logging
expect(mockLogger.error).toHaveBeenCalledWith(
'Error in remove-task tool: Unexpected error'
);
// Verify error response was returned
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unexpected error');
});
test('should properly handle withNormalizedProjectRoot wrapper', () => {
// Verify that withNormalizedProjectRoot was called with the execute function
expect(mockWithNormalizedProjectRoot).toHaveBeenCalledWith(
expect.any(Function)
);
});
test('should log appropriate info messages for successful operations', async () => {
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(validArgs, mockContext);
// Verify appropriate logging
expect(mockLogger.info).toHaveBeenCalledWith(
'Removing task(s) with ID(s): 5'
);
expect(mockLogger.info).toHaveBeenCalledWith(
'Using tasks file path: /mock/project/tasks.json'
);
expect(mockLogger.info).toHaveBeenCalledWith(
'Successfully removed task: 5'
);
});
test('should handle subtask removal with proper tag context', async () => {
const subtaskArgs = {
id: '5.2',
projectRoot: '/mock/project/root',
tag: 'feature-branch'
};
const subtaskSuccessResponse = {
success: true,
data: {
totalTasks: 1,
successful: 1,
failed: 0,
removedTasks: [
{
id: 2,
title: 'Removed Subtask',
status: 'pending',
parentTaskId: 5
}
],
messages: [
"Successfully removed subtask 5.2 from tag 'feature-branch'"
],
errors: [],
tasksPath: '/mock/project/tasks.json',
tag: 'feature-branch'
}
};
mockRemoveTaskDirect.mockResolvedValueOnce(subtaskSuccessResponse);
// Setup context
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Execute the function
await executeFunction(subtaskArgs, mockContext);
// Verify removeTaskDirect was called with subtask ID and tag
expect(mockRemoveTaskDirect).toHaveBeenCalledWith(
expect.objectContaining({
id: '5.2',
tag: 'feature-branch'
}),
mockLogger,
expect.any(Object)
);
// Verify successful handling
expect(mockHandleApiResult).toHaveBeenCalledWith(
subtaskSuccessResponse,
mockLogger,
'Error removing task',
undefined,
subtaskArgs.projectRoot
);
});
});

View File

@@ -0,0 +1,190 @@
/**
* Unit test to ensure fixDependenciesCommand writes JSON with the correct
* projectRoot and tag arguments so that tag data is preserved.
*/
import { jest } from '@jest/globals';
// Mock process.exit to prevent test termination
const mockProcessExit = jest.fn();
const originalExit = process.exit;
process.exit = mockProcessExit;
// Mock utils.js BEFORE importing the module under test
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
findProjectRoot: jest.fn(() => '/mock/project/root'),
getCurrentTag: jest.fn(() => 'master'),
taskExists: jest.fn(() => true),
formatTaskId: jest.fn((id) => id),
findCycles: jest.fn(() => []),
isSilentMode: jest.fn(() => true),
resolveTag: jest.fn(() => 'master'),
getTasksForTag: jest.fn(() => []),
setTasksForTag: jest.fn(),
enableSilentMode: jest.fn(),
disableSilentMode: jest.fn()
}));
// Mock ui.js
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
displayBanner: jest.fn()
}));
// Mock task-manager.js
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager.js',
() => ({
generateTaskFiles: jest.fn()
})
);
// Mock external libraries
jest.unstable_mockModule('chalk', () => ({
default: {
green: jest.fn((text) => text),
cyan: jest.fn((text) => text),
bold: jest.fn((text) => text)
}
}));
jest.unstable_mockModule('boxen', () => ({
default: jest.fn((text) => text)
}));
// Import the mocked modules
const { readJSON, writeJSON, log, taskExists } = await import(
'../../../../../scripts/modules/utils.js'
);
// Import the module under test
const { fixDependenciesCommand } = await import(
'../../../../../scripts/modules/dependency-manager.js'
);
describe('fixDependenciesCommand tag preservation', () => {
beforeEach(() => {
jest.clearAllMocks();
mockProcessExit.mockClear();
});
afterAll(() => {
// Restore original process.exit
process.exit = originalExit;
});
it('calls writeJSON with projectRoot and tag parameters when changes are made', async () => {
const tasksPath = '/mock/tasks.json';
const projectRoot = '/mock/project/root';
const tag = 'master';
// Mock data WITH dependency issues to trigger writeJSON
const tasksDataWithIssues = {
tasks: [
{
id: 1,
title: 'Task 1',
dependencies: [999] // Non-existent dependency to trigger fix
},
{
id: 2,
title: 'Task 2',
dependencies: []
}
],
tag: 'master',
_rawTaggedData: {
master: {
tasks: [
{
id: 1,
title: 'Task 1',
dependencies: [999]
}
]
}
}
};
readJSON.mockReturnValue(tasksDataWithIssues);
taskExists.mockReturnValue(false); // Make dependency invalid to trigger fix
await fixDependenciesCommand(tasksPath, {
context: { projectRoot, tag }
});
// Verify readJSON was called with correct parameters
expect(readJSON).toHaveBeenCalledWith(tasksPath, projectRoot, tag);
// Verify writeJSON was called (should be triggered by removing invalid dependency)
expect(writeJSON).toHaveBeenCalled();
// Check the writeJSON call parameters
const writeJSONCalls = writeJSON.mock.calls;
const lastWriteCall = writeJSONCalls[writeJSONCalls.length - 1];
const [calledPath, _data, calledProjectRoot, calledTag] = lastWriteCall;
expect(calledPath).toBe(tasksPath);
expect(calledProjectRoot).toBe(projectRoot);
expect(calledTag).toBe(tag);
// Verify process.exit was NOT called (meaning the function succeeded)
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('does not call writeJSON when no changes are needed', async () => {
const tasksPath = '/mock/tasks.json';
const projectRoot = '/mock/project/root';
const tag = 'master';
// Mock data WITHOUT dependency issues (no changes needed)
const cleanTasksData = {
tasks: [
{
id: 1,
title: 'Task 1',
dependencies: [] // Clean, no issues
}
],
tag: 'master'
};
readJSON.mockReturnValue(cleanTasksData);
taskExists.mockReturnValue(true); // All dependencies exist
await fixDependenciesCommand(tasksPath, {
context: { projectRoot, tag }
});
// Verify readJSON was called
expect(readJSON).toHaveBeenCalledWith(tasksPath, projectRoot, tag);
// Verify writeJSON was NOT called (no changes needed)
expect(writeJSON).not.toHaveBeenCalled();
// Verify process.exit was NOT called
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('handles early exit when no valid tasks found', async () => {
const tasksPath = '/mock/tasks.json';
// Mock invalid data to trigger early exit
readJSON.mockReturnValue(null);
await fixDependenciesCommand(tasksPath, {
context: { projectRoot: '/mock', tag: 'master' }
});
// Verify readJSON was called
expect(readJSON).toHaveBeenCalled();
// Verify writeJSON was NOT called (early exit)
expect(writeJSON).not.toHaveBeenCalled();
// Verify process.exit WAS called due to invalid data
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
});

View File

@@ -2,308 +2,171 @@
* Tests for the addSubtask function
*/
import { jest } from '@jest/globals';
import path from 'path';
// Mock dependencies
const mockReadJSON = jest.fn();
const mockWriteJSON = jest.fn();
const mockGenerateTaskFiles = jest.fn();
const mockIsTaskDependentOn = jest.fn().mockReturnValue(false);
// Mock path module
jest.mock('path', () => ({
dirname: jest.fn()
}));
// Define test version of the addSubtask function
const testAddSubtask = (
tasksPath,
parentId,
existingTaskId,
newSubtaskData,
generateFiles = true
) => {
// Read the existing tasks
const data = mockReadJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
// Convert parent ID to number
const parentIdNum = parseInt(parentId, 10);
// Find the parent task
const parentTask = data.tasks.find((t) => t.id === parentIdNum);
if (!parentTask) {
throw new Error(`Parent task with ID ${parentIdNum} not found`);
}
// Initialize subtasks array if it doesn't exist
if (!parentTask.subtasks) {
parentTask.subtasks = [];
}
let newSubtask;
// Case 1: Convert an existing task to a subtask
if (existingTaskId !== null) {
const existingTaskIdNum = parseInt(existingTaskId, 10);
// Find the existing task
const existingTaskIndex = data.tasks.findIndex(
(t) => t.id === existingTaskIdNum
);
if (existingTaskIndex === -1) {
throw new Error(`Task with ID ${existingTaskIdNum} not found`);
}
const existingTask = data.tasks[existingTaskIndex];
// Check if task is already a subtask
if (existingTask.parentTaskId) {
throw new Error(
`Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}`
);
}
// Check for circular dependency
if (existingTaskIdNum === parentIdNum) {
throw new Error(`Cannot make a task a subtask of itself`);
}
// Check for circular dependency using mockIsTaskDependentOn
if (mockIsTaskDependentOn()) {
throw new Error(
`Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}`
);
}
// Find the highest subtask ID to determine the next ID
const highestSubtaskId =
parentTask.subtasks.length > 0
? Math.max(...parentTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
// Clone the existing task to be converted to a subtask
newSubtask = {
...existingTask,
id: newSubtaskId,
parentTaskId: parentIdNum
};
// Add to parent's subtasks
parentTask.subtasks.push(newSubtask);
// Remove the task from the main tasks array
data.tasks.splice(existingTaskIndex, 1);
}
// Case 2: Create a new subtask
else if (newSubtaskData) {
// Find the highest subtask ID to determine the next ID
const highestSubtaskId =
parentTask.subtasks.length > 0
? Math.max(...parentTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
// Create the new subtask object
newSubtask = {
id: newSubtaskId,
title: newSubtaskData.title,
description: newSubtaskData.description || '',
details: newSubtaskData.details || '',
status: newSubtaskData.status || 'pending',
dependencies: newSubtaskData.dependencies || [],
parentTaskId: parentIdNum
};
// Add to parent's subtasks
parentTask.subtasks.push(newSubtask);
} else {
throw new Error('Either existingTaskId or newSubtaskData must be provided');
}
// Write the updated tasks back to the file
mockWriteJSON(tasksPath, data);
// Generate task files if requested
if (generateFiles) {
mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath));
}
return newSubtask;
// Mock dependencies before importing the module
const mockUtils = {
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
getCurrentTag: jest.fn()
};
const mockTaskManager = {
isTaskDependentOn: jest.fn()
};
const mockGenerateTaskFiles = jest.fn();
jest.unstable_mockModule(
'../../../../../scripts/modules/utils.js',
() => mockUtils
);
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager.js',
() => mockTaskManager
);
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager/generate-task-files.js',
() => ({
default: mockGenerateTaskFiles
})
);
const addSubtask = (
await import('../../../../../scripts/modules/task-manager/add-subtask.js')
).default;
describe('addSubtask function', () => {
// Reset mocks before each test
const multiTagData = {
master: {
tasks: [{ id: 1, title: 'Master Task', subtasks: [] }],
metadata: { description: 'Master tasks' }
},
'feature-branch': {
tasks: [{ id: 1, title: 'Feature Task', subtasks: [] }],
metadata: { description: 'Feature tasks' }
}
};
beforeEach(() => {
jest.clearAllMocks();
mockTaskManager.isTaskDependentOn.mockReturnValue(false);
});
// Default mock implementations
mockReadJSON.mockImplementation(() => ({
tasks: [
{
id: 1,
title: 'Parent Task',
description: 'This is a parent task',
status: 'pending',
dependencies: []
},
{
id: 2,
title: 'Existing Task',
description: 'This is an existing task',
status: 'pending',
dependencies: []
},
{
id: 3,
title: 'Another Task',
description: 'This is another task',
status: 'pending',
dependencies: [1]
}
]
}));
// Setup success write response
mockWriteJSON.mockImplementation((path, data) => {
return data;
test('should add a new subtask and preserve other tags', async () => {
const context = { projectRoot: '/fake/root', tag: 'feature-branch' };
const newSubtaskData = { title: 'My New Subtask' };
mockUtils.readJSON.mockReturnValueOnce({
tasks: [{ id: 1, title: 'Feature Task', subtasks: [] }],
metadata: { description: 'Feature tasks' }
});
// Set up default behavior for dependency check
mockIsTaskDependentOn.mockReturnValue(false);
await addSubtask('tasks.json', '1', null, newSubtaskData, true, context);
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
'tasks.json',
expect.any(Object),
'/fake/root',
'feature-branch'
);
const writtenData = mockUtils.writeJSON.mock.calls[0][1];
const parentTask = writtenData.tasks.find((t) => t.id === 1);
expect(parentTask.subtasks).toHaveLength(1);
expect(parentTask.subtasks[0].title).toBe('My New Subtask');
});
test('should add a new subtask to a parent task', async () => {
// Create new subtask data
const newSubtaskData = {
title: 'New Subtask',
description: 'This is a new subtask',
details: 'Implementation details for the subtask',
status: 'pending',
dependencies: []
};
// Execute the test version of addSubtask
const newSubtask = testAddSubtask(
'tasks/tasks.json',
1,
mockUtils.readJSON.mockReturnValueOnce({
tasks: [{ id: 1, title: 'Parent Task', subtasks: [] }]
});
const context = {};
const newSubtask = await addSubtask(
'tasks.json',
'1',
null,
newSubtaskData,
true
{ title: 'New Subtask' },
true,
context
);
// Verify readJSON was called with the correct path
expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json');
// Verify writeJSON was called with the correct path
expect(mockWriteJSON).toHaveBeenCalledWith(
'tasks/tasks.json',
expect.any(Object)
);
// Verify the subtask was created with correct data
expect(newSubtask).toBeDefined();
expect(newSubtask.id).toBe(1);
expect(newSubtask.title).toBe('New Subtask');
expect(newSubtask.parentTaskId).toBe(1);
// Verify generateTaskFiles was called
expect(mockUtils.writeJSON).toHaveBeenCalled();
const writeCallArgs = mockUtils.writeJSON.mock.calls[0][1]; // data is the second arg now
const parentTask = writeCallArgs.tasks.find((t) => t.id === 1);
expect(parentTask.subtasks).toHaveLength(1);
expect(parentTask.subtasks[0].title).toBe('New Subtask');
expect(mockGenerateTaskFiles).toHaveBeenCalled();
});
test('should convert an existing task to a subtask', async () => {
// Execute the test version of addSubtask to convert task 2 to a subtask of task 1
const convertedSubtask = testAddSubtask(
'tasks/tasks.json',
1,
2,
mockUtils.readJSON.mockReturnValueOnce({
tasks: [
{ id: 1, title: 'Parent Task', subtasks: [] },
{ id: 2, title: 'Existing Task 2', subtasks: [] }
]
});
const context = {};
const convertedSubtask = await addSubtask(
'tasks.json',
'1',
'2',
null,
true
true,
context
);
// Verify readJSON was called with the correct path
expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json');
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify the subtask was created with correct data
expect(convertedSubtask).toBeDefined();
expect(convertedSubtask.id).toBe(1);
expect(convertedSubtask.title).toBe('Existing Task');
expect(convertedSubtask.parentTaskId).toBe(1);
// Verify generateTaskFiles was called
expect(mockGenerateTaskFiles).toHaveBeenCalled();
expect(convertedSubtask.title).toBe('Existing Task 2');
expect(mockUtils.writeJSON).toHaveBeenCalled();
const writeCallArgs = mockUtils.writeJSON.mock.calls[0][1];
const parentTask = writeCallArgs.tasks.find((t) => t.id === 1);
expect(parentTask.subtasks).toHaveLength(1);
expect(parentTask.subtasks[0].title).toBe('Existing Task 2');
});
test('should throw an error if parent task does not exist', async () => {
// Create new subtask data
const newSubtaskData = {
title: 'New Subtask',
description: 'This is a new subtask'
};
mockUtils.readJSON.mockReturnValueOnce({
tasks: [{ id: 1, title: 'Task 1', subtasks: [] }]
});
const context = {};
await expect(
addSubtask(
'tasks.json',
'99',
null,
{ title: 'New Subtask' },
true,
context
)
).rejects.toThrow('Parent task with ID 99 not found');
});
// Override mockReadJSON for this specific test case
mockReadJSON.mockImplementationOnce(() => ({
test('should throw an error if trying to convert a non-existent task', async () => {
mockUtils.readJSON.mockReturnValueOnce({
tasks: [{ id: 1, title: 'Parent Task', subtasks: [] }]
});
const context = {};
await expect(
addSubtask('tasks.json', '1', '99', null, true, context)
).rejects.toThrow('Task with ID 99 not found');
});
test('should throw an error for circular dependency', async () => {
mockUtils.readJSON.mockReturnValueOnce({
tasks: [
{
id: 1,
title: 'Task 1',
status: 'pending'
}
{ id: 1, title: 'Parent Task', subtasks: [] },
{ id: 2, title: 'Child Task', subtasks: [] }
]
}));
// Expect an error when trying to add a subtask to a non-existent parent
expect(() =>
testAddSubtask('tasks/tasks.json', 999, null, newSubtaskData)
).toThrow(/Parent task with ID 999 not found/);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should throw an error if existing task does not exist', async () => {
// Expect an error when trying to convert a non-existent task
expect(() => testAddSubtask('tasks/tasks.json', 1, 999, null)).toThrow(
/Task with ID 999 not found/
});
mockTaskManager.isTaskDependentOn.mockImplementation(
(tasks, parentTask, existingTaskIdNum) => {
return parentTask.id === 1 && existingTaskIdNum === 2;
}
);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should throw an error if trying to create a circular dependency', async () => {
// Force the isTaskDependentOn mock to return true for this test only
mockIsTaskDependentOn.mockReturnValueOnce(true);
// Expect an error when trying to create a circular dependency
expect(() => testAddSubtask('tasks/tasks.json', 3, 1, null)).toThrow(
/circular dependency/
const context = {};
await expect(
addSubtask('tasks.json', '1', '2', null, true, context)
).rejects.toThrow(
'Cannot create circular dependency: task 1 is already a subtask or dependent of task 2'
);
// Verify writeJSON was not called
expect(mockWriteJSON).not.toHaveBeenCalled();
});
test('should not regenerate task files if generateFiles is false', async () => {
// Create new subtask data
const newSubtaskData = {
title: 'New Subtask',
description: 'This is a new subtask'
};
// Execute the test version of addSubtask with generateFiles = false
testAddSubtask('tasks/tasks.json', 1, null, newSubtaskData, false);
// Verify writeJSON was called
expect(mockWriteJSON).toHaveBeenCalled();
// Verify task files were not regenerated
expect(mockGenerateTaskFiles).not.toHaveBeenCalled();
});
});

View File

@@ -258,7 +258,9 @@ describe('addTask', () => {
})
])
})
})
}),
'/mock/project/root', // projectRoot parameter
'master' // tag parameter
);
expect(result).toEqual(
expect.objectContaining({
@@ -299,7 +301,9 @@ describe('addTask', () => {
})
])
})
})
}),
'/mock/project/root', // projectRoot parameter
'master' // tag parameter
);
});
@@ -334,7 +338,9 @@ describe('addTask', () => {
})
])
})
})
}),
'/mock/project/root', // projectRoot parameter
'master' // tag parameter
);
expect(context.mcpLog.warn).toHaveBeenCalledWith(
expect.stringContaining(
@@ -366,7 +372,9 @@ describe('addTask', () => {
})
])
})
})
}),
'/mock/project/root', // projectRoot parameter
'master' // tag parameter
);
});
@@ -401,7 +409,9 @@ describe('addTask', () => {
})
])
})
})
}),
'/mock/project/root', // projectRoot parameter
'master' // tag parameter
);
});

View File

@@ -247,7 +247,9 @@ describe('setTaskStatus', () => {
expect.objectContaining({ id: 2, status: 'done' })
])
})
})
}),
undefined,
'master'
);
// expect(generateTaskFiles).toHaveBeenCalledWith(
// tasksPath,
@@ -287,7 +289,9 @@ describe('setTaskStatus', () => {
})
])
})
})
}),
undefined,
'master'
);
});
@@ -318,7 +322,9 @@ describe('setTaskStatus', () => {
expect.objectContaining({ id: 2, status: 'done' })
])
})
})
}),
undefined,
'master'
);
});
@@ -354,7 +360,9 @@ describe('setTaskStatus', () => {
})
])
})
})
}),
undefined,
'master'
);
});
@@ -524,4 +532,45 @@ describe('setTaskStatus', () => {
);
expect(result).toBeDefined();
});
// Regression test to ensure tag preservation when updating in multi-tag environment
test('should preserve other tags when updating task status', async () => {
// Arrange
const multiTagData = {
master: JSON.parse(JSON.stringify(sampleTasks.master)),
'feature-branch': {
tasks: [
{ id: 10, title: 'FB Task', status: 'pending', dependencies: [] }
],
metadata: { description: 'Feature branch tasks' }
}
};
const tasksPath = '/mock/path/tasks.json';
readJSON.mockReturnValue({
...multiTagData.master, // resolved view not used
tag: 'master',
_rawTaggedData: multiTagData
});
// Act
await setTaskStatus(tasksPath, '1', 'done', {
mcpLog: { info: jest.fn() }
});
// Assert: writeJSON should be called with data containing both tags intact
const writeArgs = writeJSON.mock.calls[0];
expect(writeArgs[0]).toBe(tasksPath);
const writtenData = writeArgs[1];
expect(writtenData).toHaveProperty('master');
expect(writtenData).toHaveProperty('feature-branch');
// master task updated
const updatedTask = writtenData.master.tasks.find((t) => t.id === 1);
expect(updatedTask.status).toBe('done');
// feature-branch untouched
expect(writtenData['feature-branch'].tasks[0].status).toBe('pending');
// ensure additional args (projectRoot undefined, tag 'master') present
expect(writeArgs[2]).toBeUndefined();
expect(writeArgs[3]).toBe('master');
});
});

View File

@@ -0,0 +1,115 @@
import fs from 'fs';
import path from 'path';
import {
createTag,
deleteTag,
renameTag,
copyTag,
tags as listTags
} from '../../../scripts/modules/task-manager/tag-management.js';
const TEMP_DIR = path.join(process.cwd(), '.tmp_tag_management_tests');
const TASKS_PATH = path.join(TEMP_DIR, 'tasks.json');
/**
* Helper to write an initial tagged tasks.json structure
*/
function writeInitialFile() {
const initialData = {
master: {
tasks: [{ id: 1, title: 'Initial Task', status: 'pending' }],
metadata: {
created: new Date().toISOString(),
description: 'Master tag'
}
}
};
fs.mkdirSync(TEMP_DIR, { recursive: true });
fs.writeFileSync(TASKS_PATH, JSON.stringify(initialData, null, 2));
}
describe('Tag Management writeJSON context preservation', () => {
beforeEach(() => {
writeInitialFile();
});
afterEach(() => {
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
});
it('createTag should not corrupt other tags', async () => {
await createTag(
TASKS_PATH,
'feature',
{ copyFromCurrent: true },
{ projectRoot: TEMP_DIR },
'json'
);
const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8'));
expect(data.master).toBeDefined();
expect(data.feature).toBeDefined();
});
it('renameTag should keep overall structure intact', async () => {
await createTag(
TASKS_PATH,
'oldtag',
{},
{ projectRoot: TEMP_DIR },
'json'
);
await renameTag(
TASKS_PATH,
'oldtag',
'newtag',
{},
{ projectRoot: TEMP_DIR },
'json'
);
const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8'));
expect(data.newtag).toBeDefined();
expect(data.oldtag).toBeUndefined();
});
it('copyTag then deleteTag preserves other tags', async () => {
await createTag(
TASKS_PATH,
'source',
{},
{ projectRoot: TEMP_DIR },
'json'
);
await copyTag(
TASKS_PATH,
'source',
'copy',
{},
{ projectRoot: TEMP_DIR },
'json'
);
await deleteTag(
TASKS_PATH,
'copy',
{ yes: true },
{ projectRoot: TEMP_DIR },
'json'
);
const tagsList = await listTags(
TASKS_PATH,
{},
{ projectRoot: TEMP_DIR },
'json'
);
const tagNames = tagsList.tags.map((t) => t.name);
expect(tagNames).toContain('master');
expect(tagNames).toContain('source');
expect(tagNames).not.toContain('copy');
});
});