diff --git a/.changeset/hot-planets-deny.md b/.changeset/hot-planets-deny.md new file mode 100644 index 00000000..0399324f --- /dev/null +++ b/.changeset/hot-planets-deny.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": patch +--- + +Fix expand-task to use tag-specific complexity reports + +The expand-task function now correctly uses complexity reports specific to the current tag context (e.g., task-complexity-report_feature-branch.json) instead of always using the default task-complexity-report.json file. This enables proper task expansion behavior when working with multiple tag contexts. diff --git a/scripts/modules/task-manager/expand-task.js b/scripts/modules/task-manager/expand-task.js index 397e4781..4c06d8cf 100644 --- a/scripts/modules/task-manager/expand-task.js +++ b/scripts/modules/task-manager/expand-task.js @@ -2,7 +2,13 @@ import fs from 'fs'; import path from 'path'; import { z } from 'zod'; -import { log, readJSON, writeJSON, isSilentMode } from '../utils.js'; +import { + log, + readJSON, + writeJSON, + isSilentMode, + getTagAwareFilePath +} from '../utils.js'; import { startLoadingIndicator, @@ -497,9 +503,18 @@ async function expandTask( let complexityReasoningContext = ''; let systemPrompt; // Declare systemPrompt here - const complexityReportPath = path.join(projectRoot, COMPLEXITY_REPORT_FILE); + // Use tag-aware complexity report path + const complexityReportPath = getTagAwareFilePath( + COMPLEXITY_REPORT_FILE, + tag, + projectRoot + ); let taskAnalysis = null; + logger.info( + `Looking for complexity report at: ${complexityReportPath}${tag && tag !== 'master' ? ` (tag-specific for '${tag}')` : ''}` + ); + try { if (fs.existsSync(complexityReportPath)) { const complexityReport = readJSON(complexityReportPath); diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 5ec6fc55..1a47168e 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -64,6 +64,51 @@ function resolveEnvVariable(key, session = null, projectRoot = null) { return undefined; } +// --- Tag-Aware Path Resolution Utility --- + +/** + * Slugifies a tag name to be filesystem-safe + * @param {string} tagName - The tag name to slugify + * @returns {string} Slugified tag name safe for filesystem use + */ +function slugifyTagForFilePath(tagName) { + if (!tagName || typeof tagName !== 'string') { + return 'unknown-tag'; + } + + // Replace invalid filesystem characters with hyphens and clean up + return tagName + .replace(/[^a-zA-Z0-9_-]/g, '-') // Replace invalid chars with hyphens + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens + .replace(/-+/g, '-') // Collapse multiple hyphens + .toLowerCase() // Convert to lowercase + .substring(0, 50); // Limit length to prevent overly long filenames +} + +/** + * Resolves a file path to be tag-aware, following the pattern used by other commands. + * For non-master tags, appends _slugified-tagname before the file extension. + * @param {string} basePath - The base file path (e.g., '.taskmaster/reports/task-complexity-report.json') + * @param {string|null} tag - The tag name (null, undefined, or 'master' uses base path) + * @param {string} [projectRoot='.'] - The project root directory + * @returns {string} The resolved file path + */ +function getTagAwareFilePath(basePath, tag, projectRoot = '.') { + // Use path.parse and format for clean tag insertion + const parsedPath = path.parse(basePath); + if (!tag || tag === 'master') { + return path.join(projectRoot, basePath); + } + + // Slugify the tag for filesystem safety + const slugifiedTag = slugifyTagForFilePath(tag); + + // Append slugified tag before file extension + parsedPath.base = `${parsedPath.name}_${slugifiedTag}${parsedPath.ext}`; + const relativePath = path.format(parsedPath); + return path.join(projectRoot, relativePath); +} + // --- Project Root Finding Utility --- /** * Recursively searches upwards for project root starting from a given directory. @@ -1338,6 +1383,8 @@ export { addComplexityToTask, resolveEnvVariable, findProjectRoot, + getTagAwareFilePath, + slugifyTagForFilePath, aggregateTelemetry, getCurrentTag, resolveTag, diff --git a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js index 09d86477..3e6c13a6 100644 --- a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js +++ b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js @@ -2,6 +2,10 @@ * Tests for the analyze-task-complexity.js module */ import { jest } from '@jest/globals'; +import { + createGetTagAwareFilePathMock, + createSlugifyTagForFilePathMock +} from './setup.js'; // Mock the dependencies before importing the module under test jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ @@ -32,6 +36,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ ensureTagMetadata: jest.fn((tagObj) => tagObj), getCurrentTag: jest.fn(() => 'master'), flattenTasksWithSubtasks: jest.fn((tasks) => tasks), + getTagAwareFilePath: createGetTagAwareFilePathMock(), + slugifyTagForFilePath: createSlugifyTagForFilePathMock(), markMigrationForNotice: jest.fn(), performCompleteTagMigration: jest.fn(), setTasksForTag: jest.fn(), diff --git a/tests/unit/scripts/modules/task-manager/expand-task.test.js b/tests/unit/scripts/modules/task-manager/expand-task.test.js index 07c68fed..0b7a727a 100644 --- a/tests/unit/scripts/modules/task-manager/expand-task.test.js +++ b/tests/unit/scripts/modules/task-manager/expand-task.test.js @@ -3,6 +3,10 @@ */ import { jest } from '@jest/globals'; import fs from 'fs'; +import { + createGetTagAwareFilePathMock, + createSlugifyTagForFilePathMock +} from './setup.js'; // Mock the dependencies before importing the module under test jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ @@ -36,6 +40,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ } return allTasks; }), + getTagAwareFilePath: createGetTagAwareFilePathMock(), + slugifyTagForFilePath: createSlugifyTagForFilePathMock(), readComplexityReport: jest.fn(), markMigrationForNotice: jest.fn(), performCompleteTagMigration: jest.fn(), @@ -649,6 +655,61 @@ describe('expandTask', () => { }); }); + describe('Complexity Report Integration (Tag-Specific)', () => { + test('should use tag-specific complexity report when available', async () => { + // Arrange + const tasksPath = 'tasks/tasks.json'; + const taskId = '1'; // Task in feature-branch + const context = { + mcpLog: createMcpLogMock(), + projectRoot: '/mock/project/root', + tag: 'feature-branch' + }; + + // Stub fs.existsSync to simulate complexity report exists for this tag + const existsSpy = jest + .spyOn(fs, 'existsSync') + .mockImplementation((filepath) => + filepath.endsWith('task-complexity-report_feature-branch.json') + ); + + // Stub readJSON to return complexity report when reading the report path + readJSON.mockImplementation((filepath, projectRootParam, tagParam) => { + if (filepath.includes('task-complexity-report_feature-branch.json')) { + return { + complexityAnalysis: [ + { + taskId: 1, + complexityScore: 8, + recommendedSubtasks: 5, + reasoning: 'Needs five detailed steps', + expansionPrompt: 'Please break this task into 5 parts' + } + ] + }; + } + // Default tasks data for tasks.json + const sampleTasksCopy = JSON.parse(JSON.stringify(sampleTasks)); + const selectedTag = tagParam || 'master'; + return { + ...sampleTasksCopy[selectedTag], + tag: selectedTag, + _rawTaggedData: sampleTasksCopy + }; + }); + + // Act + await expandTask(tasksPath, taskId, undefined, false, '', context, false); + + // Assert - generateTextService called with systemPrompt for 5 subtasks + const callArg = generateTextService.mock.calls[0][0]; + expect(callArg.systemPrompt).toContain('Generate exactly 5 subtasks'); + + // Clean up stub + existsSpy.mockRestore(); + }); + }); + describe('Error Handling', () => { test('should handle non-existent task ID', async () => { // Arrange diff --git a/tests/unit/scripts/modules/task-manager/setup.js b/tests/unit/scripts/modules/task-manager/setup.js index 55ff6bbb..a13ee6ba 100644 --- a/tests/unit/scripts/modules/task-manager/setup.js +++ b/tests/unit/scripts/modules/task-manager/setup.js @@ -119,3 +119,45 @@ export const setupCommonMocks = () => { // Helper to create a deep copy of objects to avoid test pollution export const cloneData = (data) => JSON.parse(JSON.stringify(data)); + +/** + * Shared mock implementation for getTagAwareFilePath that matches the actual implementation + * This ensures consistent behavior across all test files, particularly regarding projectRoot handling. + * + * The key difference from previous inconsistent implementations was that some tests were not + * properly handling the projectRoot parameter, leading to different behaviors between test files. + * + * @param {string} basePath - The base file path + * @param {string|null} tag - The tag name (null, undefined, or 'master' uses base path) + * @param {string} [projectRoot='.'] - The project root directory + * @returns {string} The resolved file path + */ +export const createGetTagAwareFilePathMock = () => { + return jest.fn((basePath, tag, projectRoot = '.') => { + // Handle projectRoot consistently - this was the key fix + const fullPath = projectRoot ? `${projectRoot}/${basePath}` : basePath; + + if (!tag || tag === 'master') { + return fullPath; + } + + // Mock the slugification behavior (matches actual implementation) + const slugifiedTag = tag.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const idx = fullPath.lastIndexOf('.'); + return `${fullPath.slice(0, idx)}_${slugifiedTag}${fullPath.slice(idx)}`; + }); +}; + +/** + * Shared mock implementation for slugifyTagForFilePath that matches the actual implementation + * @param {string} tagName - The tag name to slugify + * @returns {string} Slugified tag name safe for filesystem use + */ +export const createSlugifyTagForFilePathMock = () => { + return jest.fn((tagName) => { + if (!tagName || typeof tagName !== 'string') { + return 'unknown-tag'; + } + return tagName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + }); +}; diff --git a/tests/unit/scripts/modules/utils-tag-aware-paths.test.js b/tests/unit/scripts/modules/utils-tag-aware-paths.test.js new file mode 100644 index 00000000..1fa9ad6b --- /dev/null +++ b/tests/unit/scripts/modules/utils-tag-aware-paths.test.js @@ -0,0 +1,83 @@ +/** + * Test for getTagAwareFilePath utility function + * Tests the fix for Issue #850 + */ + +import { getTagAwareFilePath } from '../../../../scripts/modules/utils.js'; +import path from 'path'; + +describe('getTagAwareFilePath utility function', () => { + const projectRoot = '/test/project'; + const basePath = '.taskmaster/reports/task-complexity-report.json'; + + it('should return base path for master tag', () => { + const result = getTagAwareFilePath(basePath, 'master', projectRoot); + const expected = path.join(projectRoot, basePath); + expect(result).toBe(expected); + }); + + it('should return base path for null tag', () => { + const result = getTagAwareFilePath(basePath, null, projectRoot); + const expected = path.join(projectRoot, basePath); + expect(result).toBe(expected); + }); + + it('should return base path for undefined tag', () => { + const result = getTagAwareFilePath(basePath, undefined, projectRoot); + const expected = path.join(projectRoot, basePath); + expect(result).toBe(expected); + }); + + it('should return tag-specific path for non-master tag', () => { + const tag = 'feature-branch'; + const result = getTagAwareFilePath(basePath, tag, projectRoot); + const expected = path.join( + projectRoot, + '.taskmaster/reports/task-complexity-report_feature-branch.json' + ); + expect(result).toBe(expected); + }); + + it('should handle different file extensions', () => { + const csvBasePath = '.taskmaster/reports/export.csv'; + const tag = 'dev-branch'; + const result = getTagAwareFilePath(csvBasePath, tag, projectRoot); + const expected = path.join( + projectRoot, + '.taskmaster/reports/export_dev-branch.csv' + ); + expect(result).toBe(expected); + }); + + it('should handle paths without extensions', () => { + const noExtPath = '.taskmaster/reports/summary'; + const tag = 'test-tag'; + const result = getTagAwareFilePath(noExtPath, tag, projectRoot); + // Since there's no extension, it should append the tag + const expected = path.join( + projectRoot, + '.taskmaster/reports/summary_test-tag' + ); + expect(result).toBe(expected); + }); + + it('should use default project root when not provided', () => { + const tag = 'feature-tag'; + const result = getTagAwareFilePath(basePath, tag); + const expected = path.join( + '.', + '.taskmaster/reports/task-complexity-report_feature-tag.json' + ); + expect(result).toBe(expected); + }); + + it('should handle complex tag names with special characters', () => { + const tag = 'feature-user-auth-v2'; + const result = getTagAwareFilePath(basePath, tag, projectRoot); + const expected = path.join( + projectRoot, + '.taskmaster/reports/task-complexity-report_feature-user-auth-v2.json' + ); + expect(result).toBe(expected); + }); +}); diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js index 6cd0962f..fc22b7c9 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -22,10 +22,26 @@ jest.mock('fs', () => ({ })); jest.mock('path', () => ({ - join: jest.fn((dir, file) => `${dir}/${file}`), + join: jest.fn((...paths) => paths.join('/')), dirname: jest.fn((filePath) => filePath.split('/').slice(0, -1).join('/')), resolve: jest.fn((...paths) => paths.join('/')), - basename: jest.fn((filePath) => filePath.split('/').pop()) + basename: jest.fn((filePath) => filePath.split('/').pop()), + parse: jest.fn((filePath) => { + const parts = filePath.split('/'); + const fileName = parts[parts.length - 1]; + const extIndex = fileName.lastIndexOf('.'); + return { + dir: parts.length > 1 ? parts.slice(0, -1).join('/') : '', + name: extIndex > 0 ? fileName.substring(0, extIndex) : fileName, + ext: extIndex > 0 ? fileName.substring(extIndex) : '', + base: fileName + }; + }), + format: jest.fn((pathObj) => { + const dir = pathObj.dir || ''; + const base = pathObj.base || `${pathObj.name || ''}${pathObj.ext || ''}`; + return dir ? `${dir}/${base}` : base; + }) })); jest.mock('chalk', () => ({ @@ -72,7 +88,9 @@ import { taskExists, formatTaskId, findCycles, - toKebabCase + toKebabCase, + slugifyTagForFilePath, + getTagAwareFilePath } from '../../scripts/modules/utils.js'; // Import the mocked modules for use in tests @@ -119,6 +137,8 @@ describe('Utils Module', () => { beforeEach(() => { // Clear all mocks before each test jest.clearAllMocks(); + // Restore the original path.join mock + jest.spyOn(path, 'join').mockImplementation((...paths) => paths.join('/')); }); describe('truncate function', () => { @@ -677,3 +697,51 @@ describe('CLI Flag Format Validation', () => { }); }); }); + +test('slugifyTagForFilePath should create filesystem-safe tag names', () => { + expect(slugifyTagForFilePath('feature/user-auth')).toBe('feature-user-auth'); + expect(slugifyTagForFilePath('Feature Branch')).toBe('feature-branch'); + expect(slugifyTagForFilePath('test@special#chars')).toBe( + 'test-special-chars' + ); + expect(slugifyTagForFilePath('UPPERCASE')).toBe('uppercase'); + expect(slugifyTagForFilePath('multiple---hyphens')).toBe('multiple-hyphens'); + expect(slugifyTagForFilePath('--leading-trailing--')).toBe( + 'leading-trailing' + ); + expect(slugifyTagForFilePath('')).toBe('unknown-tag'); + expect(slugifyTagForFilePath(null)).toBe('unknown-tag'); + expect(slugifyTagForFilePath(undefined)).toBe('unknown-tag'); +}); + +test('getTagAwareFilePath should use slugified tags in file paths', () => { + const basePath = '.taskmaster/reports/complexity-report.json'; + const projectRoot = '/test/project'; + + // Master tag should not be slugified + expect(getTagAwareFilePath(basePath, 'master', projectRoot)).toBe( + '/test/project/.taskmaster/reports/complexity-report.json' + ); + + // Null/undefined tags should use base path + expect(getTagAwareFilePath(basePath, null, projectRoot)).toBe( + '/test/project/.taskmaster/reports/complexity-report.json' + ); + + // Regular tag should be slugified + expect(getTagAwareFilePath(basePath, 'feature-branch', projectRoot)).toBe( + '/test/project/.taskmaster/reports/complexity-report_feature-branch.json' + ); + + // Tag with special characters should be slugified + expect(getTagAwareFilePath(basePath, 'feature/user-auth', projectRoot)).toBe( + '/test/project/.taskmaster/reports/complexity-report_feature-user-auth.json' + ); + + // Tag with spaces and special characters + expect( + getTagAwareFilePath(basePath, 'Feature Branch @Test', projectRoot) + ).toBe( + '/test/project/.taskmaster/reports/complexity-report_feature-branch-test.json' + ); +});