fix: use tag-specific complexity reports (#857)
* fix(expand-task): Use tag-specific complexity reports - Add getTagAwareFilePath utility function to resolve tag-specific file paths - Update expandTask to use tag-aware complexity report paths - Fix issue where expand-task always used default complexity report - Add comprehensive tests for getTagAwareFilePath utility - Ensure proper handling of file extensions and directory structures Fixes #850: Expanding tasks not using tag-specific complexity reports The expandTask 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, ensuring complexity analysis is tag-specific and accurate. * chore: Add changeset for tag-specific complexity reports fix * test(expand-task): Add tests for tag-specific complexity report integration - Introduced a new test suite for verifying the integration of tag-specific complexity reports in the expandTask function. - Added a test case to ensure the correct complexity report is used when available for a specific tag. - Mocked file system interactions to simulate the presence of tag-specific complexity reports. This enhances the test coverage for task expansion behavior, ensuring it accurately reflects the complexity analysis based on the current tag context. * refactor(task-manager): unify and simplify tag-aware file path logic and tests - Reformatted imports and cleaned up comments in test files for readability - Centralized mocks: moved getTagAwareFilePath & slugifyTagForFilePath mocks to setup.js for consistency and maintainability - Simplified utils/getTagAwareFilePath: replaced manual parsing with path.parse() & path.format(); improved extension handling - Enhanced test mocks for path.parse, path.format & reset path.join in beforeEach to avoid interference - All tests now pass consistently; no change in functionality
This commit is contained in:
7
.changeset/hot-planets-deny.md
Normal file
7
.changeset/hot-planets-deny.md
Normal file
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
83
tests/unit/scripts/modules/utils-tag-aware-paths.test.js
Normal file
83
tests/unit/scripts/modules/utils-tag-aware-paths.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user