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

588 lines
16 KiB
JavaScript

/**
* Tests for the list-tasks.js module
*/
import { jest } from '@jest/globals';
// Mock the dependencies before importing the module under test
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
CONFIG: {
model: 'mock-claude-model',
maxTokens: 4000,
temperature: 0.7,
debug: false
},
sanitizePrompt: jest.fn((prompt) => prompt),
truncate: jest.fn((text) => text),
isSilentMode: jest.fn(() => false),
findTaskById: jest.fn((tasks, id) =>
tasks.find((t) => t.id === parseInt(id))
),
addComplexityToTask: jest.fn(),
readComplexityReport: jest.fn(() => null),
getTagAwareFilePath: jest.fn((tag, path) => '/mock/tagged/report.json')
}));
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
formatDependenciesWithStatus: jest.fn(),
displayBanner: jest.fn(),
displayTaskList: jest.fn(),
startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
stopLoadingIndicator: jest.fn(),
createProgressBar: jest.fn(() => ' MOCK_PROGRESS_BAR '),
getStatusWithColor: jest.fn((status) => status),
getComplexityWithColor: jest.fn((score) => `Score: ${score}`)
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/dependency-manager.js',
() => ({
validateAndFixDependencies: jest.fn(),
validateTaskDependencies: jest.fn()
})
);
// Import the mocked modules
const { readJSON, log, readComplexityReport, addComplexityToTask } =
await import('../../../../../scripts/modules/utils.js');
const { displayTaskList } = await import(
'../../../../../scripts/modules/ui.js'
);
const { validateAndFixDependencies } = await import(
'../../../../../scripts/modules/dependency-manager.js'
);
// Import the module under test
const { default: listTasks } = await import(
'../../../../../scripts/modules/task-manager/list-tasks.js'
);
// Sample data for tests
const sampleTasks = {
meta: { projectName: 'Test Project' },
tasks: [
{
id: 1,
title: 'Setup Project',
description: 'Initialize project structure',
status: 'done',
dependencies: [],
priority: 'high'
},
{
id: 2,
title: 'Implement Core Features',
description: 'Build main functionality',
status: 'pending',
dependencies: [1],
priority: 'high'
},
{
id: 3,
title: 'Create UI Components',
description: 'Build user interface',
status: 'in-progress',
dependencies: [1, 2],
priority: 'medium',
subtasks: [
{
id: 1,
title: 'Create Header Component',
description: 'Build header component',
status: 'done',
dependencies: []
},
{
id: 2,
title: 'Create Footer Component',
description: 'Build footer component',
status: 'pending',
dependencies: [1]
}
]
},
{
id: 4,
title: 'Testing',
description: 'Write and run tests',
status: 'cancelled',
dependencies: [2, 3],
priority: 'low'
},
{
id: 5,
title: 'Code Review',
description: 'Review code for quality and standards',
status: 'review',
dependencies: [3],
priority: 'medium'
}
]
};
describe('listTasks', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock console methods to suppress output
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock process.exit to prevent actual exit
jest.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${code}`);
});
// Set up default mock return values
readJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks)));
readComplexityReport.mockReturnValue(null);
validateAndFixDependencies.mockImplementation(() => {});
displayTaskList.mockImplementation(() => {});
addComplexityToTask.mockImplementation(() => {});
});
afterEach(() => {
// Restore console methods
jest.restoreAllMocks();
});
test('should list all tasks when no status filter is provided', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, false, 'json', {
tag: 'master'
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(result).toEqual(
expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 2 }),
expect.objectContaining({ id: 3 }),
expect.objectContaining({ id: 4 }),
expect.objectContaining({ id: 5 })
])
})
);
});
test('should filter tasks by status when status filter is provided', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Verify only pending tasks are returned
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('pending');
expect(result.tasks[0].id).toBe(2);
});
test('should filter tasks by done status', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Verify only done tasks are returned
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('done');
});
test('should filter tasks by review status', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'review';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Verify only review tasks are returned
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('review');
expect(result.tasks[0].id).toBe(5);
});
test('should include subtasks when withSubtasks option is true', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, true, 'json', {
tag: 'master'
});
// Assert
// Verify that the task with subtasks is included
const taskWithSubtasks = result.tasks.find((task) => task.id === 3);
expect(taskWithSubtasks).toBeDefined();
expect(taskWithSubtasks.subtasks).toBeDefined();
expect(taskWithSubtasks.subtasks).toHaveLength(2);
});
test('should not include subtasks when withSubtasks option is false', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, false, 'json', {
tag: 'master'
});
// Assert
// For JSON output, subtasks should still be included in the data structure
// The withSubtasks flag affects display, not the data structure
expect(result).toEqual(
expect.objectContaining({
tasks: expect.any(Array)
})
);
});
test('should return empty array when no tasks match the status filter', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'blocked'; // Status that doesn't exist in sample data
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Verify empty array is returned
expect(result.tasks).toHaveLength(0);
});
test('should handle file read errors', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
readJSON.mockImplementation(() => {
throw new Error('File not found');
});
// Act & Assert
expect(() => {
listTasks(tasksPath, null, null, false, 'json', { tag: 'master' });
}).toThrow('File not found');
});
test('should validate and fix dependencies before listing', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
// Act
listTasks(tasksPath, null, null, false, 'json', { tag: 'master' });
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Note: validateAndFixDependencies is not called by listTasks function
// This test just verifies the function runs without error
});
test('should pass correct options to displayTaskList', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, 'pending', null, true, 'json', {
tag: 'master'
});
// Assert
// For JSON output, we don't call displayTaskList, so just verify the result structure
expect(result).toEqual(
expect.objectContaining({
tasks: expect.any(Array),
filter: 'pending',
stats: expect.any(Object)
})
);
});
test('should filter tasks by in-progress status', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'in-progress';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('in-progress');
expect(result.tasks[0].id).toBe(3);
});
test('should filter tasks by cancelled status', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'cancelled';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('cancelled');
expect(result.tasks[0].id).toBe(4);
});
test('should return the original tasks data structure', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
// Act
const result = listTasks(tasksPath, null, null, false, 'json', {
tag: 'master'
});
// Assert
expect(result).toEqual(
expect.objectContaining({
tasks: expect.any(Array),
filter: 'all',
stats: expect.objectContaining({
total: 5,
completed: expect.any(Number),
inProgress: expect.any(Number),
pending: expect.any(Number)
})
})
);
expect(result.tasks).toHaveLength(5);
});
// Tests for comma-separated status filtering
describe('Comma-separated status filtering', () => {
test('should filter tasks by multiple statuses separated by commas', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done,pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Should return tasks with 'done' or 'pending' status
expect(result.tasks).toHaveLength(2);
expect(result.tasks.map((t) => t.status)).toEqual(
expect.arrayContaining(['done', 'pending'])
);
});
test('should filter tasks by three or more statuses', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done,pending,in-progress';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return tasks with 'done', 'pending', or 'in-progress' status
expect(result.tasks).toHaveLength(3);
const statusValues = result.tasks.map((task) => task.status);
expect(statusValues).toEqual(
expect.arrayContaining(['done', 'pending', 'in-progress'])
);
// Verify all matching tasks are included
const taskIds = result.tasks.map((task) => task.id);
expect(taskIds).toContain(1); // done
expect(taskIds).toContain(2); // pending
expect(taskIds).toContain(3); // in-progress
expect(taskIds).not.toContain(4); // cancelled - should not be included
});
test('should handle spaces around commas in status filter', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done, pending , in-progress';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should trim spaces and work correctly
expect(result.tasks).toHaveLength(3);
const statusValues = result.tasks.map((task) => task.status);
expect(statusValues).toEqual(
expect.arrayContaining(['done', 'pending', 'in-progress'])
);
});
test('should handle empty status values in comma-separated list', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done,,pending,';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should ignore empty values and work with valid ones
expect(result.tasks).toHaveLength(2);
const statusValues = result.tasks.map((task) => task.status);
expect(statusValues).toEqual(expect.arrayContaining(['done', 'pending']));
});
test('should handle case-insensitive matching for comma-separated statuses', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'DONE,Pending,IN-PROGRESS';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should match case-insensitively
expect(result.tasks).toHaveLength(3);
const statusValues = result.tasks.map((task) => task.status);
expect(statusValues).toEqual(
expect.arrayContaining(['done', 'pending', 'in-progress'])
);
});
test('should return empty array when no tasks match comma-separated statuses', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'blocked,deferred';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return empty array as no tasks have these statuses
expect(result.tasks).toHaveLength(0);
});
test('should work with single status when using comma syntax', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'pending,';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should work the same as single status filter
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('pending');
});
test('should set correct filter value in response for comma-separated statuses', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done,pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return the original filter string
expect(result.filter).toBe('done,pending');
});
test('should handle all statuses filter with comma syntax', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'all';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return all tasks when filter is 'all'
expect(result.tasks).toHaveLength(5);
expect(result.filter).toBe('all');
});
test('should handle mixed existing and non-existing statuses', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'done,nonexistent,pending';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return only tasks with existing statuses
expect(result.tasks).toHaveLength(2);
const statusValues = result.tasks.map((task) => task.status);
expect(statusValues).toEqual(expect.arrayContaining(['done', 'pending']));
});
test('should filter by review status in comma-separated list', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const statusFilter = 'review,cancelled';
// Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', {
tag: 'master'
});
// Assert
// Should return tasks with 'review' or 'cancelled' status
expect(result.tasks).toHaveLength(2);
const statusValues = result.tasks.map((task) => task.status);
expect(statusValues).toEqual(
expect.arrayContaining(['review', 'cancelled'])
);
// Verify specific tasks
const taskIds = result.tasks.map((task) => task.id);
expect(taskIds).toContain(4); // cancelled task
expect(taskIds).toContain(5); // review task
});
});
});