-
Notifications
You must be signed in to change notification settings - Fork 2.5k
fix: escape angle brackets in markdown code blocks #1667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); | ||
|
|
||
| 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'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| } | ||
|
|
||
| /** | ||
| * Execute the actual move operation | ||
| * @param {Array} tasksToMove - Array of task IDs to move | ||
|
|
@@ -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) { | ||
|
|
@@ -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
|
||
|
|
||
| // 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); | ||
|
|
||
|
|
@@ -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
|
||
| } | ||
| // 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
|
||
| } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+943
to
985
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Subtask dependency references may be incorrectly remapped. When a dependency is in dotted format (e.g., 🐛 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 |
||
|
|
||
| return { rawData, movedTasks }; | ||
|
|
@@ -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 | ||
| }; | ||
|
|
||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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