Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/cli/src/utils/content-renderer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @fileoverview Unit tests for content-renderer
* Regression tests for markdown rendering with angle brackets in code blocks
* @see https://github.com/eyaltoledano/claude-task-master/issues/1562
*/

import { describe, expect, it } from 'vitest';
import { renderContent } from './content-renderer.js';

describe('renderContent', () => {
it('should return empty string for empty input', () => {
expect(renderContent('')).toBe('');
});

it('should render plain markdown without corruption', () => {
const input = '## Heading\\n\\nSome paragraph text.';
const result = renderContent(input);
// Should contain the heading text and paragraph — not be empty or corrupted
expect(result).toContain('Heading');
expect(result).toContain('Some paragraph text');
});

it('should not treat angle brackets inside fenced code blocks as HTML (issue #1562)', () => {
const input =
'## Implementation\\n\\n```tsx\\nexport function Component() {\\n return <Navigate to="/" replace />\\n}\\n```';
const result = renderContent(input);
// The key assertion: the output should still contain "Implementation" as a
// recognizable section and should NOT collapse into a single mangled line.
expect(result).toContain('Implementation');
// The code content should be preserved (Navigate component reference)
expect(result).toContain('Navigate');
});

it('should not treat angle brackets inside inline code as HTML', () => {
const input = 'Use the `<Navigate>` component for redirects.';
const result = renderContent(input);
expect(result).toContain('Navigate');
expect(result).toContain('redirects');
});
Comment on lines +23 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Regression assertions are too weak to catch the original one-line-collapse bug.

These tests can still pass if output is mangled into one line, because they only check keyword presence. Please assert preserved structure and raw code content more directly.

Suggested assertion hardening
 it('should not treat angle brackets inside fenced code blocks as HTML (issue `#1562`)', () => {
@@
   const result = renderContent(input);
@@
   expect(result).toContain('Implementation');
-  // The code content should be preserved (Navigate component reference)
-  expect(result).toContain('Navigate');
+  // Preserve code line content and multi-line structure
+  expect(result).toContain('<Navigate to="/" replace />');
+  expect(result.split('\n').length).toBeGreaterThan(4);
 });
 
 it('should not treat angle brackets inside inline code as HTML', () => {
@@
   const result = renderContent(input);
-  expect(result).toContain('Navigate');
+  expect(result).toContain('<Navigate>');
   expect(result).toContain('redirects');
 });
@@
 it('should handle mixed markdown with code blocks containing multiple angle brackets', () => {
@@
   const result = renderContent(input);
@@
   expect(result).toContain('Current State');
   expect(result).toContain('RequireGuest');
+  expect(result).toContain('<BrowserRouter>');
+  expect(result).toContain('<Route path="/" element={<Home />} />');
+  expect(result.split('\n').length).toBeGreaterThan(6);
 });

Also applies to: 49-69

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/utils/content-renderer.spec.ts` around lines 23 - 39, The
regression tests for renderContent are too weak because they only check keyword
presence; update the two tests ("should not treat angle brackets inside fenced
code blocks as HTML (issue `#1562`)" and "should not treat angle brackets inside
inline code as HTML") to assert preserved structure: call renderContent(input)
and assert that the output contains a code block or pre/code wrapper (e.g.,
check for '<pre', '<code', or a newline-delimited fenced block surrounding
'export function Component' and the '<Navigate' text) and that "Implementation"
appears followed by a newline or heading markup (e.g., 'Implementation' then
'\n' or '<h2' before the code), and for the inline case assert that the inline
code formatting is preserved (e.g., '`<Navigate>`' rendered as '<code' or
backticks preserved) so the tests fail if content is collapsed into a single
mangled line.


it('should still convert actual HTML content to markdown', () => {
const input = '<h1>Title</h1><p>Paragraph</p>';
const result = renderContent(input);
// Turndown should have converted this to markdown, then marked-terminal renders it
expect(result).toContain('Title');
expect(result).toContain('Paragraph');
});

it('should handle mixed markdown with code blocks containing multiple angle brackets', () => {
const input = [
'## Current State\\n\\n',
'The PRD mentions `RequireGuest` route guard.\\n\\n',
'```tsx\\n',
'function App() {\\n',
' return (\\n',
' <BrowserRouter>\\n',
' <Routes>\\n',
' <Route path="/" element={<Home />} />\\n',
' </Routes>\\n',
' </BrowserRouter>\\n',
' );\\n',
'}\\n',
'```'
].join('');
const result = renderContent(input);
// Should render the heading and prose properly, not collapse into one line
expect(result).toContain('Current State');
expect(result).toContain('RequireGuest');
});

it('should handle content with angle brackets outside code blocks as HTML', () => {
// Real HTML mixed with text (not inside code blocks) should still be converted
const input = 'Some text <strong>bold</strong> more text';
const result = renderContent(input);
expect(result).toContain('bold');
expect(result).toContain('more text');
});
});
18 changes: 16 additions & 2 deletions apps/cli/src/utils/content-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ marked.setOptions({
gfm: true
});

/**
* Check if content contains HTML tags outside of markdown code blocks and inline code.
* Strips fenced code blocks (```...```) and inline code (`...`) before testing,
* so angle brackets inside code (e.g. JSX like <Navigate />) are not mistaken for HTML.
*/
function hasHtmlOutsideCodeBlocks(text: string): boolean {
// Remove fenced code blocks (``` or ~~~, with optional language identifier)
const withoutFenced = text.replace(/(`{3,}|~{3,})[\s\S]*?\1/g, '');
// Remove inline code spans (backtick-delimited)
const withoutCode = withoutFenced.replace(/`[^`]*`/g, '');
// Now test for HTML tags only in the remaining (non-code) text
return /<[^>]+>/.test(withoutCode);
}

/**
* Convert HTML content to Markdown, then render for terminal
* Handles tiptap HTML from Hamster gracefully
Expand All @@ -68,8 +82,8 @@ export function renderContent(content: string): string {
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"');

// Check if content has HTML tags - if so, convert to markdown first
if (/<[^>]+>/.test(cleaned)) {
// Check if content has HTML tags outside of code blocks - if so, convert to markdown first
if (hasHtmlOutsideCodeBlocks(cleaned)) {
cleaned = turndownService.turndown(cleaned);
}

Expand Down
14 changes: 13 additions & 1 deletion mcp-server/src/core/direct-functions/move-task-cross-tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,23 @@ export async function moveTaskCrossTagDirect(args, log, context = {}) {
{ projectRoot }
);

// Check if any tasks were renumbered during the move
const renumberedTasks = (result.movedTasks || []).filter(
(t) => t.newId !== undefined
);
let message = `Successfully moved ${sourceIds.length} task(s) from "${args.sourceTag}" to "${args.targetTag}"`;
if (renumberedTasks.length > 0) {
const renumberDetails = renumberedTasks
.map((t) => `${t.originalId} → ${t.newId}`)
.join(', ');
message += `. Renumbered to avoid ID collisions: ${renumberDetails}`;
}
Comment on lines +120 to +130
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success message here is built from sourceIds.length, but moveTasksBetweenTags() may move additional tasks when withDependencies is true. In that case the message will report the wrong count (and may diverge from result.movedTasks.length). Prefer deriving the count from result.movedTasks.length/result.message so it stays accurate across move options.

Copilot uses AI. Check for mistakes.

return {
success: true,
data: {
...result,
message: `Successfully moved ${sourceIds.length} task(s) from "${args.sourceTag}" to "${args.targetTag}"`,
message,
moveOptions,
sourceTag: args.sourceTag,
targetTag: args.targetTag
Expand Down
139 changes: 123 additions & 16 deletions scripts/modules/task-manager/move-task.js
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,23 @@ async function resolveDependencies(
};
}

/**
* Get the next available task ID in the target tag.
* Finds the maximum existing ID among target tag tasks and any already-assigned
* new IDs from the current move batch, then returns max + 1.
* @param {Object} rawData - Raw data object
* @param {string} targetTag - Target tag name
* @param {Map} idRemapping - Map of old ID -> new ID for tasks already processed in this batch
* @returns {number} Next available task ID
*/
function getNextAvailableId(rawData, targetTag, idRemapping) {
const existingIds = rawData[targetTag].tasks.map((t) => t.id);
const remappedIds = Array.from(idRemapping.values());
const allIds = [...existingIds, ...remappedIds];
if (allIds.length === 0) return 1;
return Math.max(...allIds) + 1;
Comment on lines +828 to +833
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getNextAvailableId() assumes rawData[targetTag].tasks is always an array. If the target tag exists but has a malformed shape (e.g., missing tasks), this will throw before you can recover. Consider normalizing/initializing rawData[targetTag].tasks (or guarding with Array.isArray) inside this helper to make renumbering resilient.

Copilot uses AI. Check for mistakes.
}

/**
* Execute the actual move operation
* @param {Array} tasksToMove - Array of task IDs to move
Expand All @@ -836,6 +853,8 @@ async function executeMoveOperation(
) {
const { projectRoot } = context;
const movedTasks = [];
// Track ID remapping: oldId -> newId (only for tasks that needed renumbering)
const idRemapping = new Map();

// Move each task from source to target tag
for (const taskId of tasksToMove) {
Expand All @@ -860,22 +879,39 @@ async function executeMoveOperation(
const existingTaskIndex = rawData[targetTag].tasks.findIndex(
(t) => t.id === normalizedTaskId
);

let assignedId = normalizedTaskId;

if (existingTaskIndex !== -1) {
throw new MoveTaskError(
MOVE_ERROR_CODES.TASK_ALREADY_EXISTS,
`Task ${taskId} already exists in target tag "${targetTag}"`,
{
conflictingId: normalizedTaskId,
targetTag,
suggestions: [
'Choose a different target tag without conflicting IDs',
'Move a different set of IDs (avoid existing ones)',
'If needed, move within-tag to a new ID first, then cross-tag move'
]
}
// ID collision detected — auto-renumber to the next available ID
assignedId = getNextAvailableId(rawData, targetTag, idRemapping);
idRemapping.set(normalizedTaskId, assignedId);
log(
'info',
`Task ${normalizedTaskId} conflicts with existing ID in "${targetTag}", renumbered to ${assignedId}`
);
}
Comment on lines 885 to 893
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is titled/described as a markdown rendering fix (#1562), but this hunk changes cross-tag move semantics from throwing TASK_ALREADY_EXISTS on ID collision to auto-renumbering and continuing. That’s a user-visible behavior/API change and likely deserves mention in the PR description/title (or to be split into a separate PR) so it can be reviewed/released independently.

Copilot uses AI. Check for mistakes.

// Apply the new ID to the task
taskToMove.id = assignedId;

// Update internal subtask dependency references that pointed to the old parent ID
if (Array.isArray(taskToMove.subtasks)) {
taskToMove.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
if (typeof dep === 'string' && dep.includes('.')) {
const [depParent, depSub] = dep.split('.');
if (parseInt(depParent, 10) === normalizedTaskId) {
return `${assignedId}.${depSub}`;
}
}
return dep;
});
}
});
}

// Remove from source tag
rawData[sourceTag].tasks.splice(sourceTaskIndex, 1);

Expand All @@ -887,13 +923,65 @@ async function executeMoveOperation(
);
rawData[targetTag].tasks.push(taskWithPreservedMetadata);

movedTasks.push({
const moveEntry = {
id: taskId,
fromTag: sourceTag,
toTag: targetTag
});
};
if (assignedId !== normalizedTaskId) {
moveEntry.originalId = normalizedTaskId;
moveEntry.newId = assignedId;
}
movedTasks.push(moveEntry);

log('info', `Moved task ${taskId} from "${sourceTag}" to "${targetTag}"`);
log(
'info',
`Moved task ${taskId} from "${sourceTag}" to "${targetTag}"${assignedId !== normalizedTaskId ? ` (renumbered to ${assignedId})` : ''}`
);
}

// After all tasks are moved, update cross-references within the moved batch.
// If task A depended on task B and both were moved but B got renumbered,
// update A's dependency to point to B's new ID.
if (idRemapping.size > 0) {
for (const taskId of tasksToMove) {
const normalizedTaskId =
typeof taskId === 'string' ? parseInt(taskId, 10) : taskId;
// Find the task in the target tag (it may have been renumbered)
const finalId = idRemapping.get(normalizedTaskId) || normalizedTaskId;
const movedTask = rawData[targetTag].tasks.find(
(t) => t.id === finalId
);
if (movedTask && Array.isArray(movedTask.dependencies)) {
movedTask.dependencies = movedTask.dependencies.map((dep) => {
const normalizedDep = normalizeDependency(dep);
if (
Number.isFinite(normalizedDep) &&
idRemapping.has(normalizedDep)
) {
return idRemapping.get(normalizedDep);
}
return dep;
});
Comment on lines +956 to +965
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When updating dependencies after renumbering, dotted dependency strings like "3.1" (subtask references) will be corrupted: normalizeDependency() returns only the parent ID (3), so the current logic replaces the entire dependency with the new parent ID and drops the .1 suffix. This breaks cross-task subtask dependencies when a referenced task is renumbered. Preserve the subtask suffix when remapping (e.g., "3.1" -> "7.1"), and avoid changing the dependency value type unnecessarily.

Copilot uses AI. Check for mistakes.
}
// Also update subtask dependencies that reference remapped IDs
if (movedTask && Array.isArray(movedTask.subtasks)) {
movedTask.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
const normalizedDep = normalizeDependency(dep);
if (
Number.isFinite(normalizedDep) &&
idRemapping.has(normalizedDep)
) {
return idRemapping.get(normalizedDep);
}
return dep;
});
Comment on lines +971 to +980
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subtask dependency remapping loop also uses normalizeDependency(dep) and, on a match, replaces the full dependency entry with the new parent task ID. For dependencies expressed as dotted strings (e.g., "2.1"), this drops the subtask portion and changes semantics. Adjust the remap to preserve dotted-format dependencies (and their type) when the parent ID is renumbered.

Copilot uses AI. Check for mistakes.
}
});
}
}
}
Comment on lines +943 to 985
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Subtask dependency references may be incorrectly remapped.

When a dependency is in dotted format (e.g., "5.2" referencing subtask 2 of task 5), normalizeDependency extracts the parent ID (5). If task 5 was remapped to 6, the current code replaces the entire dependency with 6 instead of "6.2", losing the subtask reference.

🐛 Proposed fix to preserve subtask references
 			if (movedTask && Array.isArray(movedTask.dependencies)) {
 				movedTask.dependencies = movedTask.dependencies.map((dep) => {
 					const normalizedDep = normalizeDependency(dep);
 					if (
 						Number.isFinite(normalizedDep) &&
 						idRemapping.has(normalizedDep)
 					) {
+						// Preserve subtask reference if present
+						if (typeof dep === 'string' && dep.includes('.')) {
+							const [, subId] = dep.split('.');
+							return `${idRemapping.get(normalizedDep)}.${subId}`;
+						}
 						return idRemapping.get(normalizedDep);
 					}
 					return dep;
 				});
 			}
 			// Also update subtask dependencies that reference remapped IDs
 			if (movedTask && Array.isArray(movedTask.subtasks)) {
 				movedTask.subtasks.forEach((subtask) => {
 					if (Array.isArray(subtask.dependencies)) {
 						subtask.dependencies = subtask.dependencies.map((dep) => {
 							const normalizedDep = normalizeDependency(dep);
 							if (
 								Number.isFinite(normalizedDep) &&
 								idRemapping.has(normalizedDep)
 							) {
+								// Preserve subtask reference if present
+								if (typeof dep === 'string' && dep.includes('.')) {
+									const [, subId] = dep.split('.');
+									return `${idRemapping.get(normalizedDep)}.${subId}`;
+								}
 								return idRemapping.get(normalizedDep);
 							}
 							return dep;
 						});
 					}
 				});
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/modules/task-manager/move-task.js` around lines 943 - 985, The
dependency remapping loses subtask suffixes because normalizeDependency returns
the numeric parent ID and you replace the whole dep with
idRemapping.get(normalizedDep); update the remapping logic inside the
movedTask.dependencies and movedTask.subtasks[].dependencies map callbacks to:
extract the original dep string, detect and preserve any dotted suffix (e.g.,
".2"), look up the new parent ID via idRemapping.get(normalizedDep), and return
either the numeric new ID (if original was numeric) or the reconstructed dotted
string `${newParent}${suffix}` (if original had a suffix); apply this same
reconstruction for both dependency arrays referenced in the movedTask processing
(functions/variables: normalizeDependency, idRemapping, movedTask,
movedTask.subtasks).


return { rawData, movedTasks };
Expand Down Expand Up @@ -922,8 +1010,19 @@ async function finalizeMove(
// Write the updated data
writeJSON(tasksPath, rawData, projectRoot, null);

// Check if any tasks were renumbered during the move
const renumberedTasks = movedTasks.filter((t) => t.newId !== undefined);

let message = `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`;
if (renumberedTasks.length > 0) {
const renumberDetails = renumberedTasks
.map((t) => `${t.originalId} → ${t.newId}`)
.join(', ');
message += `. Renumbered to avoid ID collisions: ${renumberDetails}`;
}

const response = {
message: `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`,
message,
movedTasks
};

Expand All @@ -938,6 +1037,14 @@ async function finalizeMove(
];
}

// If tasks were renumbered, suggest validating dependencies
if (renumberedTasks.length > 0) {
if (!response.tips) response.tips = [];
response.tips.push(
'Some tasks were renumbered to avoid ID collisions. Run "task-master validate-dependencies" to verify dependency integrity.'
);
}

return response;
}

Expand Down
Loading
Loading