mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(core,cli): surface rich AI implementation metadata for remote tasks (#1521)
This commit is contained in:
19
.changeset/clever-moments-find.md
Normal file
19
.changeset/clever-moments-find.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
"task-master-ai": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Enhanced task metadata display for remote/team mode tasks
|
||||||
|
|
||||||
|
- Tasks now display rich implementation guidance in team mode including:
|
||||||
|
- **Relevant Files**: Files to create, modify, or reference with descriptions
|
||||||
|
- **Codebase Patterns**: Coding patterns and conventions to follow
|
||||||
|
- **Existing Infrastructure**: Code and utilities to leverage
|
||||||
|
- **Scope Boundaries**: What's in and out of scope for the task
|
||||||
|
- **Implementation Approach**: Step-by-step guidance
|
||||||
|
- **Technical Constraints**: Requirements and limitations
|
||||||
|
- **Acceptance Criteria**: Definition of done checklist
|
||||||
|
- **Skills & Category**: Task classification and required expertise
|
||||||
|
- How to see the new task details:
|
||||||
|
1. Create a brief on tryhamster.com
|
||||||
|
2. Generate the plan of the brief
|
||||||
|
3. View subtasks
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
import type { Task } from '@tm/core';
|
import type { Task } from '@tm/core';
|
||||||
import boxen from 'boxen';
|
import boxen from 'boxen';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import { renderContent } from '../../utils/content-renderer.js';
|
||||||
import { getBoxWidth, getComplexityWithColor } from '../../utils/ui.js';
|
import { getBoxWidth, getComplexityWithColor } from '../../utils/ui.js';
|
||||||
import { renderContent } from './task-detail.component.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next task display options
|
* Next task display options
|
||||||
|
|||||||
@@ -3,88 +3,68 @@
|
|||||||
* Displays detailed task information in a structured format
|
* Displays detailed task information in a structured format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StorageType, Subtask, Task } from '@tm/core';
|
import type {
|
||||||
|
ExistingInfrastructure,
|
||||||
|
RelevantFile,
|
||||||
|
ScopeBoundaries,
|
||||||
|
StorageType,
|
||||||
|
Subtask,
|
||||||
|
Task,
|
||||||
|
TaskCategory
|
||||||
|
} from '@tm/core';
|
||||||
import boxen from 'boxen';
|
import boxen from 'boxen';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import Table from 'cli-table3';
|
import Table from 'cli-table3';
|
||||||
import { MarkedExtension, marked } from 'marked';
|
import { renderContent } from '../../utils/content-renderer.js';
|
||||||
import { markedTerminal } from 'marked-terminal';
|
|
||||||
import TurndownService from 'turndown';
|
|
||||||
import {
|
import {
|
||||||
getComplexityWithColor,
|
getComplexityWithColor,
|
||||||
getPriorityWithColor,
|
getPriorityWithColor,
|
||||||
getStatusWithColor
|
getStatusWithColor
|
||||||
} from '../../utils/ui.js';
|
} from '../../utils/ui.js';
|
||||||
|
|
||||||
// Initialize turndown for HTML to Markdown conversion
|
// ============================================================================
|
||||||
const turndownService = new TurndownService({
|
// Constants and Helper Functions
|
||||||
headingStyle: 'atx',
|
// ============================================================================
|
||||||
codeBlockStyle: 'fenced',
|
|
||||||
bulletListMarker: '-'
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert HTML content to Markdown, then render for terminal
|
* Icons for task categories
|
||||||
* Handles tiptap HTML from Hamster gracefully
|
|
||||||
*/
|
*/
|
||||||
export function renderContent(content: string): string {
|
const CATEGORY_ICONS: Record<TaskCategory, string> = {
|
||||||
if (!content) return '';
|
research: '🔍',
|
||||||
|
design: '🎨',
|
||||||
|
development: '🔧',
|
||||||
|
testing: '🧪',
|
||||||
|
documentation: '📝',
|
||||||
|
review: '👀'
|
||||||
|
};
|
||||||
|
|
||||||
// Clean up escape characters first - order matters: handle escaped backslashes first
|
/**
|
||||||
let cleaned = content
|
* Get icon for file action
|
||||||
.replace(/\\\\/g, '\\')
|
*/
|
||||||
.replace(/\\n/g, '\n')
|
function getFileActionIcon(action: RelevantFile['action']): string {
|
||||||
.replace(/\\t/g, '\t')
|
switch (action) {
|
||||||
.replace(/\\"/g, '"');
|
case 'create':
|
||||||
|
return chalk.green('✚ CREATE');
|
||||||
// Check if content has HTML tags - if so, convert to markdown first
|
case 'modify':
|
||||||
if (/<[^>]+>/.test(cleaned)) {
|
return chalk.yellow('✎ MODIFY');
|
||||||
cleaned = turndownService.turndown(cleaned);
|
case 'reference':
|
||||||
|
return chalk.blue('👁 REFER ');
|
||||||
|
default:
|
||||||
|
return chalk.gray(' FILE ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render markdown to terminal
|
|
||||||
const result = marked(cleaned);
|
|
||||||
return typeof result === 'string' ? result.trim() : cleaned;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure marked to use terminal renderer with subtle colors
|
/**
|
||||||
marked.use(
|
* Get category display with icon
|
||||||
markedTerminal({
|
*/
|
||||||
// More subtle colors that match the overall design
|
function getCategoryDisplay(category: TaskCategory): string {
|
||||||
code: (code: string) => {
|
const icon = CATEGORY_ICONS[category] || '📋';
|
||||||
// Custom code block handler to preserve formatting
|
return `${icon} ${category}`;
|
||||||
return code
|
}
|
||||||
.split('\n')
|
|
||||||
.map((line) => ' ' + chalk.cyan(line))
|
|
||||||
.join('\n');
|
|
||||||
},
|
|
||||||
blockquote: chalk.gray.italic,
|
|
||||||
html: chalk.gray, // Any remaining HTML will be grayed out (should be rare after turndown)
|
|
||||||
heading: chalk.white.bold, // White bold for headings
|
|
||||||
hr: chalk.gray,
|
|
||||||
listitem: chalk.white, // White for list items
|
|
||||||
paragraph: chalk.white, // White for paragraphs (default text color)
|
|
||||||
strong: chalk.white.bold, // White bold for strong text
|
|
||||||
em: chalk.white.italic, // White italic for emphasis
|
|
||||||
codespan: chalk.cyan, // Cyan for inline code (no background)
|
|
||||||
del: chalk.dim.strikethrough,
|
|
||||||
link: chalk.blue,
|
|
||||||
href: chalk.blue.underline,
|
|
||||||
// Add more explicit code block handling
|
|
||||||
showSectionPrefix: false,
|
|
||||||
unescape: true,
|
|
||||||
emoji: false,
|
|
||||||
// Try to preserve whitespace in code blocks
|
|
||||||
tab: 4,
|
|
||||||
width: 120
|
|
||||||
}) as MarkedExtension
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also set marked options to preserve whitespace
|
// ============================================================================
|
||||||
marked.setOptions({
|
// Display Functions
|
||||||
breaks: true,
|
// ============================================================================
|
||||||
gfm: true
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the task header with tag
|
* Display the task header with tag
|
||||||
@@ -136,6 +116,17 @@ export function displayTaskProperties(
|
|||||||
// Render description with markdown/HTML support (handles tiptap HTML from Hamster)
|
// Render description with markdown/HTML support (handles tiptap HTML from Hamster)
|
||||||
const renderedDescription = renderContent(task.description || '');
|
const renderedDescription = renderContent(task.description || '');
|
||||||
|
|
||||||
|
// Format category with icon
|
||||||
|
const categoryDisplay = task.category
|
||||||
|
? `${getCategoryDisplay(task.category)}`
|
||||||
|
: chalk.gray('N/A');
|
||||||
|
|
||||||
|
// Format skills as badges
|
||||||
|
const skillsDisplay =
|
||||||
|
task.skills && task.skills.length > 0
|
||||||
|
? task.skills.map((s) => chalk.magenta(`[${s}]`)).join(' ')
|
||||||
|
: chalk.gray('N/A');
|
||||||
|
|
||||||
// Build the left column (labels) and right column (values)
|
// Build the left column (labels) and right column (values)
|
||||||
const labels = [
|
const labels = [
|
||||||
chalk.cyan('ID:'),
|
chalk.cyan('ID:'),
|
||||||
@@ -144,6 +135,8 @@ export function displayTaskProperties(
|
|||||||
chalk.cyan('Priority:'),
|
chalk.cyan('Priority:'),
|
||||||
chalk.cyan('Dependencies:'),
|
chalk.cyan('Dependencies:'),
|
||||||
chalk.cyan('Complexity:'),
|
chalk.cyan('Complexity:'),
|
||||||
|
chalk.cyan('Category:'),
|
||||||
|
chalk.cyan('Skills:'),
|
||||||
chalk.cyan('Description:')
|
chalk.cyan('Description:')
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -156,6 +149,8 @@ export function displayTaskProperties(
|
|||||||
typeof task.complexity === 'number'
|
typeof task.complexity === 'number'
|
||||||
? getComplexityWithColor(task.complexity)
|
? getComplexityWithColor(task.complexity)
|
||||||
: chalk.gray('N/A'),
|
: chalk.gray('N/A'),
|
||||||
|
categoryDisplay,
|
||||||
|
skillsDisplay,
|
||||||
renderedDescription
|
renderedDescription
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -273,6 +268,260 @@ export function displaySubtasks(
|
|||||||
console.log(table.toString());
|
console.log(table.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Implementation Metadata Display Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display relevant files in a structured format
|
||||||
|
*/
|
||||||
|
export function displayRelevantFiles(files: RelevantFile[]): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
|
||||||
|
const content = files
|
||||||
|
.map((file) => {
|
||||||
|
const actionIcon = getFileActionIcon(file.action);
|
||||||
|
const path = chalk.white(file.path);
|
||||||
|
const desc = chalk.gray(file.description);
|
||||||
|
return `${actionIcon} ${path}\n ${desc}`;
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(chalk.white.bold('📂 Files to Touch:') + '\n\n' + content, {
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'yellow',
|
||||||
|
width: terminalWidth
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display existing infrastructure to leverage
|
||||||
|
*/
|
||||||
|
export function displayExistingInfrastructure(
|
||||||
|
infrastructure: ExistingInfrastructure[]
|
||||||
|
): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
|
||||||
|
const content = infrastructure
|
||||||
|
.map((infra) => {
|
||||||
|
const name = chalk.cyan.bold(infra.name);
|
||||||
|
const location = chalk.gray(infra.location);
|
||||||
|
const usage = chalk.white(infra.usage);
|
||||||
|
return `${name} → ${location}\n ↳ ${usage}`;
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(chalk.white.bold('🔗 Leverage Existing Code:') + '\n\n' + content, {
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'blue',
|
||||||
|
width: terminalWidth
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display scope boundaries (what's in/out of scope)
|
||||||
|
*/
|
||||||
|
export function displayScopeBoundaries(boundaries: ScopeBoundaries): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
if (boundaries.included) {
|
||||||
|
content += chalk.green.bold('✅ In Scope:\n');
|
||||||
|
content += chalk.white(' ' + boundaries.included);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundaries.excluded) {
|
||||||
|
if (content) content += '\n\n';
|
||||||
|
content += chalk.red.bold('⛔ Out of Scope:\n');
|
||||||
|
content += chalk.gray(' ' + boundaries.excluded);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(chalk.white.bold('🎯 Scope Boundaries:') + '\n\n' + content, {
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'magenta',
|
||||||
|
width: terminalWidth
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display acceptance criteria as a checklist
|
||||||
|
*/
|
||||||
|
export function displayAcceptanceCriteria(criteria: string[]): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
|
||||||
|
const content = criteria.map((c) => chalk.white(`☐ ${c}`)).join('\n');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(chalk.white.bold('✓ Acceptance Criteria:') + '\n\n' + content, {
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'green',
|
||||||
|
width: terminalWidth
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display technical constraints
|
||||||
|
*/
|
||||||
|
export function displayTechnicalConstraints(constraints: string[]): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
|
||||||
|
const content = constraints.map((c) => chalk.yellow(`▸ ${c}`)).join('\n');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(chalk.white.bold('🔒 Technical Constraints:') + '\n\n' + content, {
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'red',
|
||||||
|
width: terminalWidth
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display implementation approach (step-by-step guide)
|
||||||
|
*/
|
||||||
|
export function displayImplementationApproach(approach: string): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
const formattedApproach = renderContent(approach);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(
|
||||||
|
chalk.white.bold('📋 Implementation Approach:') +
|
||||||
|
'\n\n' +
|
||||||
|
formattedApproach,
|
||||||
|
{
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'cyan',
|
||||||
|
width: terminalWidth
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display codebase patterns to follow
|
||||||
|
*/
|
||||||
|
export function displayCodebasePatterns(patterns: string[]): void {
|
||||||
|
const terminalWidth = process.stdout.columns * 0.95 || 100;
|
||||||
|
|
||||||
|
const content = patterns.map((p) => chalk.white(`• ${p}`)).join('\n');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
boxen(chalk.white.bold('📐 Codebase Patterns:') + '\n\n' + content, {
|
||||||
|
padding: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'gray',
|
||||||
|
width: terminalWidth
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display skills and category as inline badges
|
||||||
|
*/
|
||||||
|
export function displaySkillsAndCategory(
|
||||||
|
category?: TaskCategory,
|
||||||
|
skills?: string[]
|
||||||
|
): void {
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
output +=
|
||||||
|
chalk.gray('Category: ') +
|
||||||
|
chalk.cyan(`[${getCategoryDisplay(category)}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skills && skills.length > 0) {
|
||||||
|
if (output) output += ' ';
|
||||||
|
output +=
|
||||||
|
chalk.gray('Skills: ') +
|
||||||
|
skills.map((s) => chalk.magenta(`[${s}]`)).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
console.log('\n' + output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display all implementation metadata for a task
|
||||||
|
* Shows all AI-generated guidance when available
|
||||||
|
* Note: Category and skills are displayed in the main properties table
|
||||||
|
*/
|
||||||
|
export function displayImplementationMetadata(task: Task | Subtask): void {
|
||||||
|
const hasMetadata =
|
||||||
|
task.relevantFiles ||
|
||||||
|
task.existingInfrastructure ||
|
||||||
|
task.scopeBoundaries ||
|
||||||
|
task.acceptanceCriteria ||
|
||||||
|
task.technicalConstraints ||
|
||||||
|
task.implementationApproach ||
|
||||||
|
task.codebasePatterns;
|
||||||
|
|
||||||
|
if (!hasMetadata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display implementation approach
|
||||||
|
if (task.implementationApproach) {
|
||||||
|
console.log();
|
||||||
|
displayImplementationApproach(task.implementationApproach);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display relevant files
|
||||||
|
if (task.relevantFiles && task.relevantFiles.length > 0) {
|
||||||
|
console.log();
|
||||||
|
displayRelevantFiles(task.relevantFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display existing infrastructure
|
||||||
|
if (task.existingInfrastructure && task.existingInfrastructure.length > 0) {
|
||||||
|
console.log();
|
||||||
|
displayExistingInfrastructure(task.existingInfrastructure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display codebase patterns
|
||||||
|
if (task.codebasePatterns && task.codebasePatterns.length > 0) {
|
||||||
|
console.log();
|
||||||
|
displayCodebasePatterns(task.codebasePatterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display scope boundaries
|
||||||
|
if (task.scopeBoundaries) {
|
||||||
|
console.log();
|
||||||
|
displayScopeBoundaries(task.scopeBoundaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display technical constraints
|
||||||
|
if (task.technicalConstraints && task.technicalConstraints.length > 0) {
|
||||||
|
console.log();
|
||||||
|
displayTechnicalConstraints(task.technicalConstraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display acceptance criteria
|
||||||
|
if (task.acceptanceCriteria && task.acceptanceCriteria.length > 0) {
|
||||||
|
console.log();
|
||||||
|
displayAcceptanceCriteria(task.acceptanceCriteria);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Suggested Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display suggested actions
|
* Display suggested actions
|
||||||
*/
|
*/
|
||||||
@@ -349,6 +598,9 @@ export function displayTaskDetails(
|
|||||||
displayTestStrategy(task.testStrategy as string);
|
displayTestStrategy(task.testStrategy as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display AI implementation metadata (relevantFiles, codebasePatterns, etc.)
|
||||||
|
displayImplementationMetadata(task);
|
||||||
|
|
||||||
// Display subtasks if available
|
// Display subtasks if available
|
||||||
if (task.subtasks && task.subtasks.length > 0) {
|
if (task.subtasks && task.subtasks.length > 0) {
|
||||||
// Filter subtasks by status if provided
|
// Filter subtasks by status if provided
|
||||||
|
|||||||
79
apps/cli/src/utils/content-renderer.ts
Normal file
79
apps/cli/src/utils/content-renderer.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Content rendering utilities for CLI
|
||||||
|
* Handles HTML to Markdown conversion and terminal-friendly rendering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { MarkedExtension, marked } from 'marked';
|
||||||
|
import { markedTerminal } from 'marked-terminal';
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
|
||||||
|
// Initialize turndown for HTML to Markdown conversion
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
bulletListMarker: '-'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure marked to use terminal renderer with subtle colors
|
||||||
|
marked.use(
|
||||||
|
markedTerminal({
|
||||||
|
// More subtle colors that match the overall design
|
||||||
|
code: (code: string) => {
|
||||||
|
// Custom code block handler to preserve formatting
|
||||||
|
return code
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => ' ' + chalk.cyan(line))
|
||||||
|
.join('\n');
|
||||||
|
},
|
||||||
|
blockquote: chalk.gray.italic,
|
||||||
|
html: chalk.gray, // Any remaining HTML will be grayed out (should be rare after turndown)
|
||||||
|
heading: chalk.white.bold, // White bold for headings
|
||||||
|
hr: chalk.gray,
|
||||||
|
listitem: chalk.white, // White for list items
|
||||||
|
paragraph: chalk.white, // White for paragraphs (default text color)
|
||||||
|
strong: chalk.white.bold, // White bold for strong text
|
||||||
|
em: chalk.white.italic, // White italic for emphasis
|
||||||
|
codespan: chalk.cyan, // Cyan for inline code (no background)
|
||||||
|
del: chalk.dim.strikethrough,
|
||||||
|
link: chalk.blue,
|
||||||
|
href: chalk.blue.underline,
|
||||||
|
// Add more explicit code block handling
|
||||||
|
showSectionPrefix: false,
|
||||||
|
unescape: true,
|
||||||
|
emoji: false,
|
||||||
|
// Try to preserve whitespace in code blocks
|
||||||
|
tab: 4,
|
||||||
|
width: 120
|
||||||
|
}) as MarkedExtension
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also set marked options to preserve whitespace
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML content to Markdown, then render for terminal
|
||||||
|
* Handles tiptap HTML from Hamster gracefully
|
||||||
|
*/
|
||||||
|
export function renderContent(content: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Clean up escape characters first - order matters: handle escaped backslashes first
|
||||||
|
let cleaned = content
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\t/g, '\t')
|
||||||
|
.replace(/\\"/g, '"');
|
||||||
|
|
||||||
|
// Check if content has HTML tags - if so, convert to markdown first
|
||||||
|
if (/<[^>]+>/.test(cleaned)) {
|
||||||
|
cleaned = turndownService.turndown(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown to terminal
|
||||||
|
const result = marked(cleaned);
|
||||||
|
return typeof result === 'string' ? result.trim() : cleaned;
|
||||||
|
}
|
||||||
@@ -46,3 +46,6 @@ export {
|
|||||||
|
|
||||||
// Display helpers (command-specific helpers)
|
// Display helpers (command-specific helpers)
|
||||||
export { displayCommandHeader } from './display-helpers.js';
|
export { displayCommandHeader } from './display-helpers.js';
|
||||||
|
|
||||||
|
// Content rendering (HTML/Markdown to terminal)
|
||||||
|
export { renderContent } from './content-renderer.js';
|
||||||
|
|||||||
423
packages/tm-core/src/common/mappers/TaskMapper.spec.ts
Normal file
423
packages/tm-core/src/common/mappers/TaskMapper.spec.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for TaskMapper
|
||||||
|
*
|
||||||
|
* Tests the mapping of database task rows to internal Task format,
|
||||||
|
* with focus on metadata extraction including AI implementation guidance fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TaskMapper } from './TaskMapper.js';
|
||||||
|
import { MetadataFixtures } from '../../testing/task-fixtures.js';
|
||||||
|
import type { Tables } from '../types/database.types.js';
|
||||||
|
|
||||||
|
type TaskRow = Tables<'tasks'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock database task row for testing
|
||||||
|
*/
|
||||||
|
function createMockTaskRow(overrides: Partial<TaskRow> = {}): TaskRow {
|
||||||
|
return {
|
||||||
|
id: 'uuid-123',
|
||||||
|
display_id: 'HAM-1',
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'Test description',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
brief_id: 'brief-123',
|
||||||
|
parent_task_id: null,
|
||||||
|
position: 1,
|
||||||
|
subtask_position: 0,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
metadata: {},
|
||||||
|
complexity: null,
|
||||||
|
estimated_hours: null,
|
||||||
|
actual_hours: 0,
|
||||||
|
assignee_id: null,
|
||||||
|
document_id: null,
|
||||||
|
account_id: 'account-123',
|
||||||
|
created_by: 'user-123',
|
||||||
|
updated_by: 'user-123',
|
||||||
|
completed_subtasks: 0,
|
||||||
|
total_subtasks: 0,
|
||||||
|
due_date: null,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskMapper', () => {
|
||||||
|
describe('extractImplementationMetadata', () => {
|
||||||
|
it('should extract all fields from complete metadata', () => {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(
|
||||||
|
MetadataFixtures.completeMetadata
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toEqual([
|
||||||
|
{
|
||||||
|
path: 'src/service.ts',
|
||||||
|
description: 'Main service file',
|
||||||
|
action: 'modify'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(result.codebasePatterns).toEqual([
|
||||||
|
'Use dependency injection',
|
||||||
|
'Follow SOLID principles'
|
||||||
|
]);
|
||||||
|
expect(result.existingInfrastructure).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Logger',
|
||||||
|
location: 'src/common/logger.ts',
|
||||||
|
usage: 'Use for structured logging'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(result.scopeBoundaries).toEqual({
|
||||||
|
included: 'Core functionality',
|
||||||
|
excluded: 'UI changes'
|
||||||
|
});
|
||||||
|
expect(result.implementationApproach).toBe(
|
||||||
|
'Step-by-step implementation guide'
|
||||||
|
);
|
||||||
|
expect(result.technicalConstraints).toEqual([
|
||||||
|
'Must be backwards compatible'
|
||||||
|
]);
|
||||||
|
expect(result.acceptanceCriteria).toEqual([
|
||||||
|
'Feature works as expected',
|
||||||
|
'Tests pass'
|
||||||
|
]);
|
||||||
|
expect(result.skills).toEqual(['TypeScript', 'Node.js']);
|
||||||
|
expect(result.category).toBe('development');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for missing fields', () => {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(
|
||||||
|
MetadataFixtures.minimalMetadata
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toBeUndefined();
|
||||||
|
expect(result.codebasePatterns).toBeUndefined();
|
||||||
|
expect(result.existingInfrastructure).toBeUndefined();
|
||||||
|
expect(result.scopeBoundaries).toBeUndefined();
|
||||||
|
expect(result.implementationApproach).toBeUndefined();
|
||||||
|
expect(result.technicalConstraints).toBeUndefined();
|
||||||
|
expect(result.acceptanceCriteria).toBeUndefined();
|
||||||
|
expect(result.skills).toBeUndefined();
|
||||||
|
expect(result.category).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null metadata gracefully', () => {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(null);
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toBeUndefined();
|
||||||
|
expect(result.codebasePatterns).toBeUndefined();
|
||||||
|
expect(result.category).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined metadata gracefully', () => {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(undefined);
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toBeUndefined();
|
||||||
|
expect(result.skills).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty metadata object', () => {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(
|
||||||
|
MetadataFixtures.emptyMetadata
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toBeUndefined();
|
||||||
|
expect(result.codebasePatterns).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter invalid items from arrays', () => {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(
|
||||||
|
MetadataFixtures.malformedMetadata
|
||||||
|
);
|
||||||
|
|
||||||
|
// codebasePatterns has [123, null, 'valid'] - should only keep 'valid'
|
||||||
|
expect(result.codebasePatterns).toEqual(['valid']);
|
||||||
|
|
||||||
|
// relevantFiles is 'not-an-array' - should be undefined
|
||||||
|
expect(result.relevantFiles).toBeUndefined();
|
||||||
|
|
||||||
|
// existingInfrastructure has invalid structure - should be undefined
|
||||||
|
expect(result.existingInfrastructure).toBeUndefined();
|
||||||
|
|
||||||
|
// scopeBoundaries is 'not-an-object' - should be undefined
|
||||||
|
expect(result.scopeBoundaries).toBeUndefined();
|
||||||
|
|
||||||
|
// category is 'invalid-category' - should be undefined
|
||||||
|
expect(result.category).toBeUndefined();
|
||||||
|
|
||||||
|
// skills is an object - should be undefined
|
||||||
|
expect(result.skills).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate relevantFiles structure', () => {
|
||||||
|
const metadata = {
|
||||||
|
relevantFiles: [
|
||||||
|
{ path: 'valid.ts', description: 'Valid file', action: 'modify' },
|
||||||
|
{ path: 'missing-action.ts', description: 'Missing action' }, // Invalid
|
||||||
|
{
|
||||||
|
path: 'invalid-action.ts',
|
||||||
|
description: 'Bad action',
|
||||||
|
action: 'delete'
|
||||||
|
}, // Invalid action
|
||||||
|
{ description: 'No path', action: 'create' }, // Missing path
|
||||||
|
'not-an-object' // Invalid type
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(metadata);
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toEqual([
|
||||||
|
{ path: 'valid.ts', description: 'Valid file', action: 'modify' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate existingInfrastructure structure', () => {
|
||||||
|
const metadata = {
|
||||||
|
existingInfrastructure: [
|
||||||
|
{ name: 'Valid', location: 'src/valid.ts', usage: 'Use it' },
|
||||||
|
{ name: 'Missing location', usage: 'Use it' }, // Invalid
|
||||||
|
{ location: 'src/no-name.ts', usage: 'Use it' }, // Invalid
|
||||||
|
{ name: 'No usage', location: 'src/test.ts' }, // Invalid
|
||||||
|
'not-an-object' // Invalid type
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = TaskMapper.extractImplementationMetadata(metadata);
|
||||||
|
|
||||||
|
expect(result.existingInfrastructure).toEqual([
|
||||||
|
{ name: 'Valid', location: 'src/valid.ts', usage: 'Use it' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scopeBoundaries with partial data', () => {
|
||||||
|
const metadataIncludedOnly = {
|
||||||
|
scopeBoundaries: { included: 'Just included' }
|
||||||
|
};
|
||||||
|
const resultIncluded =
|
||||||
|
TaskMapper.extractImplementationMetadata(metadataIncludedOnly);
|
||||||
|
expect(resultIncluded.scopeBoundaries).toEqual({
|
||||||
|
included: 'Just included'
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataExcludedOnly = {
|
||||||
|
scopeBoundaries: { excluded: 'Just excluded' }
|
||||||
|
};
|
||||||
|
const resultExcluded =
|
||||||
|
TaskMapper.extractImplementationMetadata(metadataExcludedOnly);
|
||||||
|
expect(resultExcluded.scopeBoundaries).toEqual({
|
||||||
|
excluded: 'Just excluded'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empty scopeBoundaries object should return undefined
|
||||||
|
const metadataEmpty = {
|
||||||
|
scopeBoundaries: {}
|
||||||
|
};
|
||||||
|
const resultEmpty =
|
||||||
|
TaskMapper.extractImplementationMetadata(metadataEmpty);
|
||||||
|
expect(resultEmpty.scopeBoundaries).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate category enum values', () => {
|
||||||
|
const validCategories = [
|
||||||
|
'research',
|
||||||
|
'design',
|
||||||
|
'development',
|
||||||
|
'testing',
|
||||||
|
'documentation',
|
||||||
|
'review'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const category of validCategories) {
|
||||||
|
const result = TaskMapper.extractImplementationMetadata({ category });
|
||||||
|
expect(result.category).toBe(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid category
|
||||||
|
const invalidResult = TaskMapper.extractImplementationMetadata({
|
||||||
|
category: 'invalid'
|
||||||
|
});
|
||||||
|
expect(invalidResult.category).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapDatabaseTaskToTask', () => {
|
||||||
|
it('should map basic task fields correctly', () => {
|
||||||
|
const dbTask = createMockTaskRow();
|
||||||
|
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
expect(result.id).toBe('HAM-1');
|
||||||
|
expect(result.databaseId).toBe('uuid-123');
|
||||||
|
expect(result.title).toBe('Test Task');
|
||||||
|
expect(result.description).toBe('Test description');
|
||||||
|
expect(result.status).toBe('pending'); // 'todo' maps to 'pending'
|
||||||
|
expect(result.priority).toBe('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract implementation metadata from task', () => {
|
||||||
|
const dbTask = createMockTaskRow({
|
||||||
|
metadata: MetadataFixtures.completeMetadata
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
expect(result.relevantFiles).toBeDefined();
|
||||||
|
expect(result.relevantFiles?.[0].path).toBe('src/service.ts');
|
||||||
|
expect(result.codebasePatterns).toEqual([
|
||||||
|
'Use dependency injection',
|
||||||
|
'Follow SOLID principles'
|
||||||
|
]);
|
||||||
|
expect(result.category).toBe('development');
|
||||||
|
expect(result.skills).toEqual(['TypeScript', 'Node.js']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add undefined metadata fields to result', () => {
|
||||||
|
const dbTask = createMockTaskRow({
|
||||||
|
metadata: MetadataFixtures.minimalMetadata
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
// These should not be present as properties (not just undefined values)
|
||||||
|
expect('relevantFiles' in result).toBe(false);
|
||||||
|
expect('codebasePatterns' in result).toBe(false);
|
||||||
|
expect('category' in result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map subtasks with implementation metadata', () => {
|
||||||
|
const parentTask = createMockTaskRow({ id: 'parent-uuid' });
|
||||||
|
const subtask = createMockTaskRow({
|
||||||
|
id: 'subtask-uuid',
|
||||||
|
display_id: 'HAM-1.1',
|
||||||
|
parent_task_id: 'parent-uuid',
|
||||||
|
metadata: {
|
||||||
|
details: 'Subtask details',
|
||||||
|
testStrategy: 'Subtask tests',
|
||||||
|
category: 'testing',
|
||||||
|
skills: ['Jest', 'Vitest'],
|
||||||
|
acceptanceCriteria: ['Tests pass', 'Coverage > 80%']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TaskMapper.mapDatabaseTaskToTask(
|
||||||
|
parentTask,
|
||||||
|
[subtask],
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.subtasks).toHaveLength(1);
|
||||||
|
const mappedSubtask = result.subtasks[0];
|
||||||
|
expect(mappedSubtask.id).toBe('HAM-1.1');
|
||||||
|
expect(mappedSubtask.category).toBe('testing');
|
||||||
|
expect(mappedSubtask.skills).toEqual(['Jest', 'Vitest']);
|
||||||
|
expect(mappedSubtask.acceptanceCriteria).toEqual([
|
||||||
|
'Tests pass',
|
||||||
|
'Coverage > 80%'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map status correctly', () => {
|
||||||
|
const todoTask = createMockTaskRow({ status: 'todo' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(todoTask, [], new Map()).status
|
||||||
|
).toBe('pending');
|
||||||
|
|
||||||
|
const inProgressTask = createMockTaskRow({ status: 'in_progress' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(inProgressTask, [], new Map()).status
|
||||||
|
).toBe('in-progress');
|
||||||
|
|
||||||
|
const doneTask = createMockTaskRow({ status: 'done' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(doneTask, [], new Map()).status
|
||||||
|
).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map priority correctly', () => {
|
||||||
|
const urgentTask = createMockTaskRow({ priority: 'urgent' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(urgentTask, [], new Map()).priority
|
||||||
|
).toBe('critical');
|
||||||
|
|
||||||
|
const highTask = createMockTaskRow({ priority: 'high' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(highTask, [], new Map()).priority
|
||||||
|
).toBe('high');
|
||||||
|
|
||||||
|
const mediumTask = createMockTaskRow({ priority: 'medium' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(mediumTask, [], new Map()).priority
|
||||||
|
).toBe('medium');
|
||||||
|
|
||||||
|
const lowTask = createMockTaskRow({ priority: 'low' });
|
||||||
|
expect(
|
||||||
|
TaskMapper.mapDatabaseTaskToTask(lowTask, [], new Map()).priority
|
||||||
|
).toBe('low');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapDatabaseTasksToTasks', () => {
|
||||||
|
it('should group subtasks under parent tasks', () => {
|
||||||
|
const parentTask = createMockTaskRow({
|
||||||
|
id: 'parent-uuid',
|
||||||
|
display_id: 'HAM-1'
|
||||||
|
});
|
||||||
|
const subtask1 = createMockTaskRow({
|
||||||
|
id: 'subtask-1-uuid',
|
||||||
|
display_id: 'HAM-1.1',
|
||||||
|
parent_task_id: 'parent-uuid',
|
||||||
|
subtask_position: 1
|
||||||
|
});
|
||||||
|
const subtask2 = createMockTaskRow({
|
||||||
|
id: 'subtask-2-uuid',
|
||||||
|
display_id: 'HAM-1.2',
|
||||||
|
parent_task_id: 'parent-uuid',
|
||||||
|
subtask_position: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TaskMapper.mapDatabaseTasksToTasks(
|
||||||
|
[parentTask, subtask1, subtask2],
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('HAM-1');
|
||||||
|
expect(result[0].subtasks).toHaveLength(2);
|
||||||
|
expect(result[0].subtasks[0].id).toBe('HAM-1.1');
|
||||||
|
expect(result[0].subtasks[1].id).toBe('HAM-1.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty task list', () => {
|
||||||
|
const result = TaskMapper.mapDatabaseTasksToTasks([], new Map());
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null task list', () => {
|
||||||
|
const result = TaskMapper.mapDatabaseTasksToTasks(
|
||||||
|
null as unknown as TaskRow[],
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include implementation metadata in mapped tasks', () => {
|
||||||
|
const task = createMockTaskRow({
|
||||||
|
metadata: {
|
||||||
|
details: 'Task details',
|
||||||
|
implementationApproach: 'Step by step guide',
|
||||||
|
technicalConstraints: ['Constraint 1', 'Constraint 2']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TaskMapper.mapDatabaseTasksToTasks([task], new Map());
|
||||||
|
|
||||||
|
expect(result[0].implementationApproach).toBe('Step by step guide');
|
||||||
|
expect(result[0].technicalConstraints).toEqual([
|
||||||
|
'Constraint 1',
|
||||||
|
'Constraint 2'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Database, Tables } from '../types/database.types.js';
|
import { Database, Tables } from '../types/database.types.js';
|
||||||
import { Subtask, Task } from '../types/index.js';
|
import type {
|
||||||
|
ExistingInfrastructure,
|
||||||
|
RelevantFile,
|
||||||
|
ScopeBoundaries,
|
||||||
|
Subtask,
|
||||||
|
Task,
|
||||||
|
TaskCategory,
|
||||||
|
TaskImplementationMetadata
|
||||||
|
} from '../types/index.js';
|
||||||
|
|
||||||
type TaskRow = Tables<'tasks'>;
|
type TaskRow = Tables<'tasks'>;
|
||||||
|
|
||||||
@@ -51,27 +59,35 @@ export class TaskMapper {
|
|||||||
dbSubtasks: TaskRow[],
|
dbSubtasks: TaskRow[],
|
||||||
dependenciesByTaskId: Map<string, string[]>
|
dependenciesByTaskId: Map<string, string[]>
|
||||||
): Task {
|
): Task {
|
||||||
// Map subtasks
|
// Map subtasks with implementation metadata
|
||||||
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({
|
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => {
|
||||||
id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
|
const implMeta = this.extractImplementationMetadata(subtask.metadata);
|
||||||
parentId: dbTask.id,
|
return {
|
||||||
title: subtask.title,
|
id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
|
||||||
description: subtask.description || '',
|
parentId: dbTask.id,
|
||||||
status: this.mapStatus(subtask.status),
|
title: subtask.title,
|
||||||
priority: this.mapPriority(subtask.priority),
|
description: subtask.description || '',
|
||||||
dependencies: dependenciesByTaskId.get(subtask.id) || [],
|
status: this.mapStatus(subtask.status),
|
||||||
details: this.extractMetadataField(subtask.metadata, 'details', ''),
|
priority: this.mapPriority(subtask.priority),
|
||||||
testStrategy: this.extractMetadataField(
|
dependencies: dependenciesByTaskId.get(subtask.id) || [],
|
||||||
subtask.metadata,
|
details: this.extractMetadataField(subtask.metadata, 'details', ''),
|
||||||
'testStrategy',
|
testStrategy: this.extractMetadataField(
|
||||||
''
|
subtask.metadata,
|
||||||
),
|
'testStrategy',
|
||||||
createdAt: subtask.created_at,
|
''
|
||||||
updatedAt: subtask.updated_at,
|
),
|
||||||
assignee: subtask.assignee_id || undefined,
|
createdAt: subtask.created_at,
|
||||||
complexity: subtask.complexity ?? undefined,
|
updatedAt: subtask.updated_at,
|
||||||
databaseId: subtask.id // Include the actual database UUID
|
assignee: subtask.assignee_id || undefined,
|
||||||
}));
|
complexity: subtask.complexity ?? undefined,
|
||||||
|
databaseId: subtask.id, // Include the actual database UUID
|
||||||
|
// Spread implementation metadata (only defined values)
|
||||||
|
...this.filterUndefined(implMeta)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract implementation metadata for the task
|
||||||
|
const implMeta = this.extractImplementationMetadata(dbTask.metadata);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: dbTask.display_id || dbTask.id, // Use display_id if available
|
id: dbTask.display_id || dbTask.id, // Use display_id if available
|
||||||
@@ -93,7 +109,9 @@ export class TaskMapper {
|
|||||||
assignee: dbTask.assignee_id || undefined,
|
assignee: dbTask.assignee_id || undefined,
|
||||||
complexity: dbTask.complexity ?? undefined,
|
complexity: dbTask.complexity ?? undefined,
|
||||||
effort: dbTask.estimated_hours || undefined,
|
effort: dbTask.estimated_hours || undefined,
|
||||||
actualEffort: dbTask.actual_hours || undefined
|
actualEffort: dbTask.actual_hours || undefined,
|
||||||
|
// Spread implementation metadata (only defined values)
|
||||||
|
...this.filterUndefined(implMeta)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,4 +233,182 @@ export class TaskMapper {
|
|||||||
|
|
||||||
return value as T;
|
return value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts an optional string field from metadata
|
||||||
|
*/
|
||||||
|
private static extractOptionalString(
|
||||||
|
metadata: unknown,
|
||||||
|
field: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (metadata as Record<string, unknown>)[field];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts an optional string array from metadata
|
||||||
|
*/
|
||||||
|
private static extractStringArray(
|
||||||
|
metadata: unknown,
|
||||||
|
field: string
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (metadata as Record<string, unknown>)[field];
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Filter to only valid strings
|
||||||
|
const strings = value.filter(
|
||||||
|
(item): item is string => typeof item === 'string'
|
||||||
|
);
|
||||||
|
return strings.length > 0 ? strings : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts RelevantFile[] from metadata
|
||||||
|
*/
|
||||||
|
private static extractRelevantFiles(
|
||||||
|
metadata: unknown
|
||||||
|
): RelevantFile[] | undefined {
|
||||||
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (metadata as Record<string, unknown>).relevantFiles;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = value.filter((item): item is RelevantFile => {
|
||||||
|
if (!item || typeof item !== 'object') return false;
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof obj.path === 'string' &&
|
||||||
|
typeof obj.description === 'string' &&
|
||||||
|
(obj.action === 'create' ||
|
||||||
|
obj.action === 'modify' ||
|
||||||
|
obj.action === 'reference')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return validFiles.length > 0 ? validFiles : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts ExistingInfrastructure[] from metadata
|
||||||
|
*/
|
||||||
|
private static extractExistingInfrastructure(
|
||||||
|
metadata: unknown
|
||||||
|
): ExistingInfrastructure[] | undefined {
|
||||||
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (metadata as Record<string, unknown>).existingInfrastructure;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validInfra = value.filter((item): item is ExistingInfrastructure => {
|
||||||
|
if (!item || typeof item !== 'object') return false;
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof obj.name === 'string' &&
|
||||||
|
typeof obj.location === 'string' &&
|
||||||
|
typeof obj.usage === 'string'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return validInfra.length > 0 ? validInfra : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts ScopeBoundaries from metadata
|
||||||
|
*/
|
||||||
|
private static extractScopeBoundaries(
|
||||||
|
metadata: unknown
|
||||||
|
): ScopeBoundaries | undefined {
|
||||||
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (metadata as Record<string, unknown>).scopeBoundaries;
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const result: ScopeBoundaries = {};
|
||||||
|
|
||||||
|
if (typeof obj.included === 'string') {
|
||||||
|
result.included = obj.included;
|
||||||
|
}
|
||||||
|
if (typeof obj.excluded === 'string') {
|
||||||
|
result.excluded = obj.excluded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return undefined if no valid fields
|
||||||
|
return result.included || result.excluded ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts TaskCategory from metadata
|
||||||
|
*/
|
||||||
|
private static extractCategory(metadata: unknown): TaskCategory | undefined {
|
||||||
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (metadata as Record<string, unknown>).category;
|
||||||
|
const validCategories: TaskCategory[] = [
|
||||||
|
'research',
|
||||||
|
'design',
|
||||||
|
'development',
|
||||||
|
'testing',
|
||||||
|
'documentation',
|
||||||
|
'review'
|
||||||
|
];
|
||||||
|
return validCategories.includes(value as TaskCategory)
|
||||||
|
? (value as TaskCategory)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all AI implementation metadata fields from database metadata
|
||||||
|
*/
|
||||||
|
static extractImplementationMetadata(
|
||||||
|
metadata: unknown
|
||||||
|
): TaskImplementationMetadata {
|
||||||
|
return {
|
||||||
|
relevantFiles: this.extractRelevantFiles(metadata),
|
||||||
|
codebasePatterns: this.extractStringArray(metadata, 'codebasePatterns'),
|
||||||
|
existingInfrastructure: this.extractExistingInfrastructure(metadata),
|
||||||
|
scopeBoundaries: this.extractScopeBoundaries(metadata),
|
||||||
|
implementationApproach: this.extractOptionalString(
|
||||||
|
metadata,
|
||||||
|
'implementationApproach'
|
||||||
|
),
|
||||||
|
technicalConstraints: this.extractStringArray(
|
||||||
|
metadata,
|
||||||
|
'technicalConstraints'
|
||||||
|
),
|
||||||
|
acceptanceCriteria: this.extractStringArray(
|
||||||
|
metadata,
|
||||||
|
'acceptanceCriteria'
|
||||||
|
),
|
||||||
|
skills: this.extractStringArray(metadata, 'skills'),
|
||||||
|
category: this.extractCategory(metadata)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out undefined values from an object
|
||||||
|
* Used to avoid adding undefined properties to task objects
|
||||||
|
*/
|
||||||
|
private static filterUndefined<T extends object>(obj: T): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).filter(([_, v]) => v !== undefined)
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,80 @@ export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
|||||||
*/
|
*/
|
||||||
export type TaskComplexity = 'simple' | 'moderate' | 'complex' | 'very-complex';
|
export type TaskComplexity = 'simple' | 'moderate' | 'complex' | 'very-complex';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Metadata Types (from Supabase task metadata)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File relevant to implementing a task/subtask
|
||||||
|
*/
|
||||||
|
export interface RelevantFile {
|
||||||
|
/** File path relative to project root */
|
||||||
|
path: string;
|
||||||
|
/** What this file contains and how it relates to the task */
|
||||||
|
description: string;
|
||||||
|
/** Whether to create, modify, or just reference this file */
|
||||||
|
action: 'create' | 'modify' | 'reference';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing infrastructure to leverage
|
||||||
|
*/
|
||||||
|
export interface ExistingInfrastructure {
|
||||||
|
/** Name of the existing service/module/infrastructure */
|
||||||
|
name: string;
|
||||||
|
/** Where it exists in the codebase */
|
||||||
|
location: string;
|
||||||
|
/** How to use or integrate with it */
|
||||||
|
usage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope boundaries for a task
|
||||||
|
*/
|
||||||
|
export interface ScopeBoundaries {
|
||||||
|
/** What is explicitly in scope for this task */
|
||||||
|
included?: string;
|
||||||
|
/** What is explicitly out of scope (belongs to other tasks/already exists) */
|
||||||
|
excluded?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task work category
|
||||||
|
*/
|
||||||
|
export type TaskCategory =
|
||||||
|
| 'research'
|
||||||
|
| 'design'
|
||||||
|
| 'development'
|
||||||
|
| 'testing'
|
||||||
|
| 'documentation'
|
||||||
|
| 'review';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI-generated implementation guidance metadata
|
||||||
|
* These fields provide rich context for AI agents and developers
|
||||||
|
*/
|
||||||
|
export interface TaskImplementationMetadata {
|
||||||
|
/** Files relevant to implementing this task */
|
||||||
|
relevantFiles?: RelevantFile[];
|
||||||
|
/** Existing code patterns, conventions, or architectural principles to follow */
|
||||||
|
codebasePatterns?: string[];
|
||||||
|
/** Existing services, modules, or infrastructure to leverage */
|
||||||
|
existingInfrastructure?: ExistingInfrastructure[];
|
||||||
|
/** Clear boundaries of what this task should and should not do */
|
||||||
|
scopeBoundaries?: ScopeBoundaries;
|
||||||
|
/** Step-by-step implementation guidance or pseudo-code */
|
||||||
|
implementationApproach?: string;
|
||||||
|
/** Framework requirements, architecture decisions, or technical limitations */
|
||||||
|
technicalConstraints?: string[];
|
||||||
|
/** Acceptance criteria defining when this task is complete */
|
||||||
|
acceptanceCriteria?: string[];
|
||||||
|
/** Required technical skills to complete this task */
|
||||||
|
skills?: string[];
|
||||||
|
/** Category of work this task represents */
|
||||||
|
category?: TaskCategory;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Core Interfaces
|
// Core Interfaces
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -54,7 +128,7 @@ export interface PlaceholderTask {
|
|||||||
/**
|
/**
|
||||||
* Base task interface
|
* Base task interface
|
||||||
*/
|
*/
|
||||||
export interface Task {
|
export interface Task extends TaskImplementationMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export {
|
|||||||
createSubtask,
|
createSubtask,
|
||||||
createTasksFile,
|
createTasksFile,
|
||||||
TaskScenarios,
|
TaskScenarios,
|
||||||
|
MetadataFixtures,
|
||||||
type TasksFile
|
type TasksFile
|
||||||
} from './task-fixtures.js';
|
} from './task-fixtures.js';
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,29 @@ export function createTask(
|
|||||||
}),
|
}),
|
||||||
...(overrides.complexityReasoning && {
|
...(overrides.complexityReasoning && {
|
||||||
complexityReasoning: overrides.complexityReasoning
|
complexityReasoning: overrides.complexityReasoning
|
||||||
})
|
}),
|
||||||
|
// AI implementation metadata fields
|
||||||
|
...(overrides.relevantFiles && { relevantFiles: overrides.relevantFiles }),
|
||||||
|
...(overrides.codebasePatterns && {
|
||||||
|
codebasePatterns: overrides.codebasePatterns
|
||||||
|
}),
|
||||||
|
...(overrides.existingInfrastructure && {
|
||||||
|
existingInfrastructure: overrides.existingInfrastructure
|
||||||
|
}),
|
||||||
|
...(overrides.scopeBoundaries && {
|
||||||
|
scopeBoundaries: overrides.scopeBoundaries
|
||||||
|
}),
|
||||||
|
...(overrides.implementationApproach && {
|
||||||
|
implementationApproach: overrides.implementationApproach
|
||||||
|
}),
|
||||||
|
...(overrides.technicalConstraints && {
|
||||||
|
technicalConstraints: overrides.technicalConstraints
|
||||||
|
}),
|
||||||
|
...(overrides.acceptanceCriteria && {
|
||||||
|
acceptanceCriteria: overrides.acceptanceCriteria
|
||||||
|
}),
|
||||||
|
...(overrides.skills && { skills: overrides.skills }),
|
||||||
|
...(overrides.category && { category: overrides.category })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +156,29 @@ export function createSubtask(
|
|||||||
}),
|
}),
|
||||||
...(overrides.complexityReasoning && {
|
...(overrides.complexityReasoning && {
|
||||||
complexityReasoning: overrides.complexityReasoning
|
complexityReasoning: overrides.complexityReasoning
|
||||||
})
|
}),
|
||||||
|
// AI implementation metadata fields
|
||||||
|
...(overrides.relevantFiles && { relevantFiles: overrides.relevantFiles }),
|
||||||
|
...(overrides.codebasePatterns && {
|
||||||
|
codebasePatterns: overrides.codebasePatterns
|
||||||
|
}),
|
||||||
|
...(overrides.existingInfrastructure && {
|
||||||
|
existingInfrastructure: overrides.existingInfrastructure
|
||||||
|
}),
|
||||||
|
...(overrides.scopeBoundaries && {
|
||||||
|
scopeBoundaries: overrides.scopeBoundaries
|
||||||
|
}),
|
||||||
|
...(overrides.implementationApproach && {
|
||||||
|
implementationApproach: overrides.implementationApproach
|
||||||
|
}),
|
||||||
|
...(overrides.technicalConstraints && {
|
||||||
|
technicalConstraints: overrides.technicalConstraints
|
||||||
|
}),
|
||||||
|
...(overrides.acceptanceCriteria && {
|
||||||
|
acceptanceCriteria: overrides.acceptanceCriteria
|
||||||
|
}),
|
||||||
|
...(overrides.skills && { skills: overrides.skills }),
|
||||||
|
...(overrides.category && { category: overrides.category })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,5 +347,149 @@ export const TaskScenarios = {
|
|||||||
/**
|
/**
|
||||||
* Empty task list
|
* Empty task list
|
||||||
*/
|
*/
|
||||||
empty: () => createTasksFile({ tasks: [] })
|
empty: () => createTasksFile({ tasks: [] }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task with rich AI-generated implementation metadata
|
||||||
|
*/
|
||||||
|
taskWithImplementationMetadata: () =>
|
||||||
|
createTasksFile({
|
||||||
|
tasks: [
|
||||||
|
createTask({
|
||||||
|
id: 1,
|
||||||
|
title: 'Implement User Authentication',
|
||||||
|
description: 'Add JWT-based authentication to the API',
|
||||||
|
details: 'Implement secure JWT authentication with refresh tokens',
|
||||||
|
testStrategy:
|
||||||
|
'Unit tests for auth functions, integration tests for flow',
|
||||||
|
category: 'development',
|
||||||
|
skills: ['TypeScript', 'JWT', 'Security'],
|
||||||
|
relevantFiles: [
|
||||||
|
{
|
||||||
|
path: 'src/auth/auth.service.ts',
|
||||||
|
description: 'Main authentication service',
|
||||||
|
action: 'modify'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'src/auth/jwt.strategy.ts',
|
||||||
|
description: 'JWT passport strategy',
|
||||||
|
action: 'create'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
codebasePatterns: [
|
||||||
|
'Use dependency injection for services',
|
||||||
|
'Follow repository pattern for data access'
|
||||||
|
],
|
||||||
|
existingInfrastructure: [
|
||||||
|
{
|
||||||
|
name: 'UserRepository',
|
||||||
|
location: 'src/users/user.repository.ts',
|
||||||
|
usage: 'Use for user lookups during authentication'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
scopeBoundaries: {
|
||||||
|
included: 'JWT token generation, validation, and refresh',
|
||||||
|
excluded: 'OAuth integration (handled in task 2)'
|
||||||
|
},
|
||||||
|
implementationApproach:
|
||||||
|
'1. Create JWT strategy\n2. Add auth guards\n3. Implement refresh token flow',
|
||||||
|
technicalConstraints: [
|
||||||
|
'Must use RS256 algorithm',
|
||||||
|
'Tokens must expire in 15 minutes'
|
||||||
|
],
|
||||||
|
acceptanceCriteria: [
|
||||||
|
'Users can login with email/password',
|
||||||
|
'JWT tokens are issued on successful login',
|
||||||
|
'Refresh tokens work correctly'
|
||||||
|
],
|
||||||
|
subtasks: [
|
||||||
|
createSubtask({
|
||||||
|
id: '1.1',
|
||||||
|
title: 'Create JWT Strategy',
|
||||||
|
category: 'development',
|
||||||
|
skills: ['TypeScript', 'Passport.js'],
|
||||||
|
relevantFiles: [
|
||||||
|
{
|
||||||
|
path: 'src/auth/jwt.strategy.ts',
|
||||||
|
description: 'JWT passport strategy implementation',
|
||||||
|
action: 'create'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
acceptanceCriteria: ['Strategy validates JWT tokens correctly']
|
||||||
|
}),
|
||||||
|
createSubtask({
|
||||||
|
id: '1.2',
|
||||||
|
title: 'Implement Auth Guards',
|
||||||
|
category: 'development',
|
||||||
|
implementationApproach: 'Create NestJS guards using JWT strategy',
|
||||||
|
technicalConstraints: ['Must work with role-based access control']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample metadata fixtures for testing metadata extraction
|
||||||
|
*/
|
||||||
|
export const MetadataFixtures = {
|
||||||
|
/**
|
||||||
|
* Complete metadata object with all fields populated
|
||||||
|
*/
|
||||||
|
completeMetadata: {
|
||||||
|
details: 'Detailed task requirements and scope',
|
||||||
|
testStrategy: 'Unit and integration tests',
|
||||||
|
relevantFiles: [
|
||||||
|
{
|
||||||
|
path: 'src/service.ts',
|
||||||
|
description: 'Main service file',
|
||||||
|
action: 'modify' as const
|
||||||
|
}
|
||||||
|
],
|
||||||
|
codebasePatterns: ['Use dependency injection', 'Follow SOLID principles'],
|
||||||
|
existingInfrastructure: [
|
||||||
|
{
|
||||||
|
name: 'Logger',
|
||||||
|
location: 'src/common/logger.ts',
|
||||||
|
usage: 'Use for structured logging'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
scopeBoundaries: {
|
||||||
|
included: 'Core functionality',
|
||||||
|
excluded: 'UI changes'
|
||||||
|
},
|
||||||
|
implementationApproach: 'Step-by-step implementation guide',
|
||||||
|
technicalConstraints: ['Must be backwards compatible'],
|
||||||
|
acceptanceCriteria: ['Feature works as expected', 'Tests pass'],
|
||||||
|
skills: ['TypeScript', 'Node.js'],
|
||||||
|
category: 'development' as const
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal metadata with only required fields
|
||||||
|
*/
|
||||||
|
minimalMetadata: {
|
||||||
|
details: 'Basic details',
|
||||||
|
testStrategy: 'Basic tests'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata with invalid/malformed data (for testing robustness)
|
||||||
|
*/
|
||||||
|
malformedMetadata: {
|
||||||
|
details: 123, // Should be string
|
||||||
|
testStrategy: null, // Should be string
|
||||||
|
relevantFiles: 'not-an-array', // Should be array
|
||||||
|
codebasePatterns: [123, null, 'valid'], // Mixed invalid types
|
||||||
|
existingInfrastructure: [{ invalid: 'structure' }], // Missing required fields
|
||||||
|
scopeBoundaries: 'not-an-object', // Should be object
|
||||||
|
category: 'invalid-category', // Invalid enum value
|
||||||
|
skills: { not: 'an-array' } // Should be array
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty metadata object
|
||||||
|
*/
|
||||||
|
emptyMetadata: {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Integration tests for task metadata extraction across storage modes
|
||||||
|
*
|
||||||
|
* These tests verify that rich AI-generated implementation metadata is correctly
|
||||||
|
* extracted and passed through the storage layer for both file and API storage modes.
|
||||||
|
*
|
||||||
|
* For API storage: Tests the flow from database rows -> TaskMapper -> Task type
|
||||||
|
* For file storage: Tests that metadata is preserved in JSON serialization/deserialization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TaskMapper } from '../../../src/common/mappers/TaskMapper.js';
|
||||||
|
import type { Json, Tables } from '../../../src/common/types/database.types.js';
|
||||||
|
import type {
|
||||||
|
ExistingInfrastructure,
|
||||||
|
RelevantFile,
|
||||||
|
Task
|
||||||
|
} from '../../../src/common/types/index.js';
|
||||||
|
|
||||||
|
type TaskRow = Tables<'tasks'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a realistic database task row with AI-generated metadata
|
||||||
|
*/
|
||||||
|
function createDatabaseTaskRow(overrides: Partial<TaskRow> = {}): TaskRow {
|
||||||
|
return {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
display_id: 'HAM-1',
|
||||||
|
title: 'Implement Authentication System',
|
||||||
|
description: 'Add JWT-based authentication to the API',
|
||||||
|
status: 'in_progress',
|
||||||
|
priority: 'high',
|
||||||
|
brief_id: 'brief-550e8400',
|
||||||
|
parent_task_id: null,
|
||||||
|
position: 1,
|
||||||
|
subtask_position: 0,
|
||||||
|
created_at: '2024-01-15T10:00:00Z',
|
||||||
|
updated_at: '2024-01-15T14:30:00Z',
|
||||||
|
metadata: {
|
||||||
|
details:
|
||||||
|
'Implement secure JWT authentication with refresh tokens and role-based access control',
|
||||||
|
testStrategy:
|
||||||
|
'Unit tests for auth functions, integration tests for login flow, E2E tests for protected routes',
|
||||||
|
relevantFiles: [
|
||||||
|
{
|
||||||
|
path: 'src/auth/auth.service.ts',
|
||||||
|
description: 'Main authentication service handling login/logout',
|
||||||
|
action: 'modify'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'src/auth/jwt.strategy.ts',
|
||||||
|
description: 'Passport JWT strategy for token validation',
|
||||||
|
action: 'create'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'src/auth/guards/auth.guard.ts',
|
||||||
|
description: 'NestJS guard for protected routes',
|
||||||
|
action: 'create'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
codebasePatterns: [
|
||||||
|
'Use dependency injection for all services',
|
||||||
|
'Follow repository pattern for data access',
|
||||||
|
'Use DTOs for request/response validation'
|
||||||
|
],
|
||||||
|
existingInfrastructure: [
|
||||||
|
{
|
||||||
|
name: 'UserRepository',
|
||||||
|
location: 'src/users/user.repository.ts',
|
||||||
|
usage: 'Use for user lookups during authentication'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ConfigService',
|
||||||
|
location: 'src/config/config.service.ts',
|
||||||
|
usage: 'Access JWT secret and token expiry settings'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
scopeBoundaries: {
|
||||||
|
included:
|
||||||
|
'JWT token generation, validation, refresh token flow, auth guards',
|
||||||
|
excluded:
|
||||||
|
'OAuth/social login (separate task), password reset (separate task)'
|
||||||
|
},
|
||||||
|
implementationApproach: `1. Create JWT strategy extending PassportStrategy
|
||||||
|
2. Implement AuthService with login/validateUser methods
|
||||||
|
3. Create AuthGuard for route protection
|
||||||
|
4. Add refresh token endpoint
|
||||||
|
5. Update user entity with hashed password storage`,
|
||||||
|
technicalConstraints: [
|
||||||
|
'Must use RS256 algorithm for JWT signing',
|
||||||
|
'Access tokens must expire in 15 minutes',
|
||||||
|
'Refresh tokens must expire in 7 days',
|
||||||
|
'Passwords must be hashed with bcrypt (cost factor 12)'
|
||||||
|
],
|
||||||
|
acceptanceCriteria: [
|
||||||
|
'Users can register with email and password',
|
||||||
|
'Users can login and receive JWT tokens',
|
||||||
|
'Protected routes reject requests without valid tokens',
|
||||||
|
'Refresh tokens can be used to get new access tokens',
|
||||||
|
'All auth-related errors return appropriate HTTP status codes'
|
||||||
|
],
|
||||||
|
skills: ['TypeScript', 'NestJS', 'JWT', 'Passport.js', 'bcrypt'],
|
||||||
|
category: 'development'
|
||||||
|
} as Json,
|
||||||
|
complexity: 7,
|
||||||
|
estimated_hours: 16,
|
||||||
|
actual_hours: 0,
|
||||||
|
assignee_id: null,
|
||||||
|
document_id: null,
|
||||||
|
account_id: 'account-123',
|
||||||
|
created_by: 'user-123',
|
||||||
|
updated_by: 'user-123',
|
||||||
|
completed_subtasks: 0,
|
||||||
|
total_subtasks: 3,
|
||||||
|
due_date: '2024-01-20T17:00:00Z',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a database subtask row with implementation metadata
|
||||||
|
*/
|
||||||
|
function createDatabaseSubtaskRow(
|
||||||
|
parentId: string,
|
||||||
|
subtaskNum: number
|
||||||
|
): TaskRow {
|
||||||
|
const subtaskMetadata: Record<number, object> = {
|
||||||
|
1: {
|
||||||
|
details: 'Create the JWT strategy class that validates tokens',
|
||||||
|
testStrategy: 'Unit tests for token validation logic',
|
||||||
|
relevantFiles: [
|
||||||
|
{
|
||||||
|
path: 'src/auth/jwt.strategy.ts',
|
||||||
|
description: 'JWT strategy implementation',
|
||||||
|
action: 'create'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
implementationApproach:
|
||||||
|
'Extend PassportStrategy with jwt-passport, implement validate method',
|
||||||
|
acceptanceCriteria: [
|
||||||
|
'Strategy validates JWT tokens',
|
||||||
|
'Invalid tokens are rejected'
|
||||||
|
],
|
||||||
|
skills: ['TypeScript', 'Passport.js'],
|
||||||
|
category: 'development'
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
details: 'Create guards for protecting routes',
|
||||||
|
testStrategy: 'Integration tests with mock requests',
|
||||||
|
relevantFiles: [
|
||||||
|
{
|
||||||
|
path: 'src/auth/guards/auth.guard.ts',
|
||||||
|
description: 'Main auth guard',
|
||||||
|
action: 'create'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
technicalConstraints: ['Must work with NestJS execution context'],
|
||||||
|
acceptanceCriteria: ['Protected routes require valid JWT'],
|
||||||
|
category: 'development'
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
details: 'Implement refresh token flow',
|
||||||
|
testStrategy: 'E2E tests for token refresh',
|
||||||
|
scopeBoundaries: {
|
||||||
|
included: 'Refresh token generation and validation',
|
||||||
|
excluded: 'Token revocation (future enhancement)'
|
||||||
|
},
|
||||||
|
acceptanceCriteria: [
|
||||||
|
'Refresh tokens can get new access tokens',
|
||||||
|
'Expired refresh tokens are rejected'
|
||||||
|
],
|
||||||
|
category: 'development'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `subtask-${subtaskNum}-uuid`,
|
||||||
|
display_id: `HAM-1.${subtaskNum}`,
|
||||||
|
title: `Subtask ${subtaskNum}`,
|
||||||
|
description: `Description for subtask ${subtaskNum}`,
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
brief_id: 'brief-550e8400',
|
||||||
|
parent_task_id: parentId,
|
||||||
|
position: 1,
|
||||||
|
subtask_position: subtaskNum,
|
||||||
|
created_at: '2024-01-15T10:00:00Z',
|
||||||
|
updated_at: '2024-01-15T10:00:00Z',
|
||||||
|
metadata: subtaskMetadata[subtaskNum] as Json,
|
||||||
|
complexity: null,
|
||||||
|
estimated_hours: 4,
|
||||||
|
actual_hours: 0,
|
||||||
|
assignee_id: null,
|
||||||
|
document_id: null,
|
||||||
|
account_id: 'account-123',
|
||||||
|
created_by: 'user-123',
|
||||||
|
updated_by: 'user-123',
|
||||||
|
completed_subtasks: 0,
|
||||||
|
total_subtasks: 0,
|
||||||
|
due_date: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Task Metadata Extraction - Integration Tests', () => {
|
||||||
|
describe('API Storage Mode - TaskMapper Integration', () => {
|
||||||
|
it('should extract complete implementation metadata from database task', () => {
|
||||||
|
const dbTask = createDatabaseTaskRow();
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
// Verify core fields
|
||||||
|
expect(task.id).toBe('HAM-1');
|
||||||
|
expect(task.title).toBe('Implement Authentication System');
|
||||||
|
expect(task.status).toBe('in-progress');
|
||||||
|
expect(task.priority).toBe('high');
|
||||||
|
|
||||||
|
// Verify details and testStrategy
|
||||||
|
expect(task.details).toContain('JWT authentication');
|
||||||
|
expect(task.testStrategy).toContain('Unit tests');
|
||||||
|
|
||||||
|
// Verify implementation metadata
|
||||||
|
expect(task.relevantFiles).toBeDefined();
|
||||||
|
expect(task.relevantFiles).toHaveLength(3);
|
||||||
|
expect(task.relevantFiles![0]).toEqual({
|
||||||
|
path: 'src/auth/auth.service.ts',
|
||||||
|
description: 'Main authentication service handling login/logout',
|
||||||
|
action: 'modify'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.codebasePatterns).toEqual([
|
||||||
|
'Use dependency injection for all services',
|
||||||
|
'Follow repository pattern for data access',
|
||||||
|
'Use DTOs for request/response validation'
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(task.existingInfrastructure).toHaveLength(2);
|
||||||
|
expect(task.existingInfrastructure![0].name).toBe('UserRepository');
|
||||||
|
|
||||||
|
expect(task.scopeBoundaries).toEqual({
|
||||||
|
included:
|
||||||
|
'JWT token generation, validation, refresh token flow, auth guards',
|
||||||
|
excluded:
|
||||||
|
'OAuth/social login (separate task), password reset (separate task)'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.implementationApproach).toContain('Create JWT strategy');
|
||||||
|
expect(task.technicalConstraints).toContain(
|
||||||
|
'Must use RS256 algorithm for JWT signing'
|
||||||
|
);
|
||||||
|
expect(task.acceptanceCriteria).toContain(
|
||||||
|
'Users can register with email and password'
|
||||||
|
);
|
||||||
|
expect(task.skills).toEqual([
|
||||||
|
'TypeScript',
|
||||||
|
'NestJS',
|
||||||
|
'JWT',
|
||||||
|
'Passport.js',
|
||||||
|
'bcrypt'
|
||||||
|
]);
|
||||||
|
expect(task.category).toBe('development');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract metadata from subtasks', () => {
|
||||||
|
const parentTask = createDatabaseTaskRow();
|
||||||
|
const subtasks = [
|
||||||
|
createDatabaseSubtaskRow(parentTask.id, 1),
|
||||||
|
createDatabaseSubtaskRow(parentTask.id, 2),
|
||||||
|
createDatabaseSubtaskRow(parentTask.id, 3)
|
||||||
|
];
|
||||||
|
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(
|
||||||
|
parentTask,
|
||||||
|
subtasks,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.subtasks).toHaveLength(3);
|
||||||
|
|
||||||
|
// Verify first subtask has metadata
|
||||||
|
const subtask1 = task.subtasks[0];
|
||||||
|
expect(subtask1.id).toBe('HAM-1.1');
|
||||||
|
expect(subtask1.relevantFiles).toHaveLength(1);
|
||||||
|
expect(subtask1.skills).toEqual(['TypeScript', 'Passport.js']);
|
||||||
|
expect(subtask1.category).toBe('development');
|
||||||
|
expect(subtask1.acceptanceCriteria).toContain(
|
||||||
|
'Strategy validates JWT tokens'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify second subtask has different metadata
|
||||||
|
const subtask2 = task.subtasks[1];
|
||||||
|
expect(subtask2.technicalConstraints).toContain(
|
||||||
|
'Must work with NestJS execution context'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify third subtask has scope boundaries
|
||||||
|
const subtask3 = task.subtasks[2];
|
||||||
|
expect(subtask3.scopeBoundaries).toBeDefined();
|
||||||
|
expect(subtask3.scopeBoundaries!.included).toContain('Refresh token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tasks without metadata gracefully', () => {
|
||||||
|
const dbTask = createDatabaseTaskRow({
|
||||||
|
metadata: {} as Json
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
expect(task.id).toBe('HAM-1');
|
||||||
|
expect(task.details).toBe('');
|
||||||
|
expect(task.testStrategy).toBe('');
|
||||||
|
expect(task.relevantFiles).toBeUndefined();
|
||||||
|
expect(task.codebasePatterns).toBeUndefined();
|
||||||
|
expect(task.category).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed metadata without crashing', () => {
|
||||||
|
const dbTask = createDatabaseTaskRow({
|
||||||
|
metadata: {
|
||||||
|
details: 123, // Invalid: should be string
|
||||||
|
relevantFiles: 'not-an-array', // Invalid: should be array
|
||||||
|
category: 'invalid-category', // Invalid: not a valid enum value
|
||||||
|
skills: { wrong: 'type' } // Invalid: should be array
|
||||||
|
} as unknown as Json
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
// Should not crash and should use defaults
|
||||||
|
expect(task.id).toBe('HAM-1');
|
||||||
|
expect(task.details).toBe(''); // Falls back to empty string
|
||||||
|
expect(task.relevantFiles).toBeUndefined();
|
||||||
|
expect(task.category).toBeUndefined();
|
||||||
|
expect(task.skills).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map multiple tasks with their subtasks correctly', () => {
|
||||||
|
const parentTask1 = createDatabaseTaskRow({
|
||||||
|
id: 'parent-1-uuid',
|
||||||
|
display_id: 'HAM-1'
|
||||||
|
});
|
||||||
|
const parentTask2 = createDatabaseTaskRow({
|
||||||
|
id: 'parent-2-uuid',
|
||||||
|
display_id: 'HAM-2',
|
||||||
|
title: 'Second Task'
|
||||||
|
});
|
||||||
|
const subtask1_1 = createDatabaseSubtaskRow('parent-1-uuid', 1);
|
||||||
|
const subtask2_1: TaskRow = {
|
||||||
|
...createDatabaseSubtaskRow('parent-2-uuid', 1),
|
||||||
|
display_id: 'HAM-2.1'
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTasks = [parentTask1, parentTask2, subtask1_1, subtask2_1];
|
||||||
|
const tasks = TaskMapper.mapDatabaseTasksToTasks(allTasks, new Map());
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(2);
|
||||||
|
expect(tasks[0].id).toBe('HAM-1');
|
||||||
|
expect(tasks[0].subtasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].subtasks[0].id).toBe('HAM-1.1');
|
||||||
|
|
||||||
|
expect(tasks[1].id).toBe('HAM-2');
|
||||||
|
expect(tasks[1].subtasks).toHaveLength(1);
|
||||||
|
expect(tasks[1].subtasks[0].id).toBe('HAM-2.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Storage Mode - JSON Serialization', () => {
|
||||||
|
it('should preserve all metadata fields through JSON serialization', () => {
|
||||||
|
// Create a task with full metadata (simulating what would be stored in tasks.json)
|
||||||
|
const taskWithMetadata: Task = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'Test description',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'high',
|
||||||
|
dependencies: [],
|
||||||
|
details: 'Detailed requirements',
|
||||||
|
testStrategy: 'Unit and integration tests',
|
||||||
|
subtasks: [],
|
||||||
|
relevantFiles: [
|
||||||
|
{ path: 'src/test.ts', description: 'Test file', action: 'modify' }
|
||||||
|
],
|
||||||
|
codebasePatterns: ['Pattern 1', 'Pattern 2'],
|
||||||
|
existingInfrastructure: [
|
||||||
|
{ name: 'Service', location: 'src/service.ts', usage: 'Use for X' }
|
||||||
|
],
|
||||||
|
scopeBoundaries: { included: 'A', excluded: 'B' },
|
||||||
|
implementationApproach: 'Step by step',
|
||||||
|
technicalConstraints: ['Constraint 1'],
|
||||||
|
acceptanceCriteria: ['Criteria 1', 'Criteria 2'],
|
||||||
|
skills: ['TypeScript'],
|
||||||
|
category: 'development'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize and deserialize (simulating file storage)
|
||||||
|
const serialized = JSON.stringify(taskWithMetadata);
|
||||||
|
const deserialized: Task = JSON.parse(serialized);
|
||||||
|
|
||||||
|
// All fields should be preserved
|
||||||
|
expect(deserialized.relevantFiles).toEqual(
|
||||||
|
taskWithMetadata.relevantFiles
|
||||||
|
);
|
||||||
|
expect(deserialized.codebasePatterns).toEqual(
|
||||||
|
taskWithMetadata.codebasePatterns
|
||||||
|
);
|
||||||
|
expect(deserialized.existingInfrastructure).toEqual(
|
||||||
|
taskWithMetadata.existingInfrastructure
|
||||||
|
);
|
||||||
|
expect(deserialized.scopeBoundaries).toEqual(
|
||||||
|
taskWithMetadata.scopeBoundaries
|
||||||
|
);
|
||||||
|
expect(deserialized.implementationApproach).toBe(
|
||||||
|
taskWithMetadata.implementationApproach
|
||||||
|
);
|
||||||
|
expect(deserialized.technicalConstraints).toEqual(
|
||||||
|
taskWithMetadata.technicalConstraints
|
||||||
|
);
|
||||||
|
expect(deserialized.acceptanceCriteria).toEqual(
|
||||||
|
taskWithMetadata.acceptanceCriteria
|
||||||
|
);
|
||||||
|
expect(deserialized.skills).toEqual(taskWithMetadata.skills);
|
||||||
|
expect(deserialized.category).toBe(taskWithMetadata.category);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tasks without optional metadata in JSON', () => {
|
||||||
|
const minimalTask: Task = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Minimal Task',
|
||||||
|
description: 'Description',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'medium',
|
||||||
|
dependencies: [],
|
||||||
|
details: '',
|
||||||
|
testStrategy: '',
|
||||||
|
subtasks: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(minimalTask);
|
||||||
|
const deserialized: Task = JSON.parse(serialized);
|
||||||
|
|
||||||
|
expect(deserialized.id).toBe('1');
|
||||||
|
expect(deserialized.relevantFiles).toBeUndefined();
|
||||||
|
expect(deserialized.category).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Metadata Type Validation', () => {
|
||||||
|
it('should correctly type relevantFiles entries', () => {
|
||||||
|
const dbTask = createDatabaseTaskRow();
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
// TypeScript type checking - if these compile, types are correct
|
||||||
|
const files: RelevantFile[] | undefined = task.relevantFiles;
|
||||||
|
expect(files).toBeDefined();
|
||||||
|
|
||||||
|
if (files) {
|
||||||
|
const firstFile: RelevantFile = files[0];
|
||||||
|
expect(firstFile.path).toBe('src/auth/auth.service.ts');
|
||||||
|
expect(firstFile.action).toBe('modify');
|
||||||
|
expect(['create', 'modify', 'reference']).toContain(firstFile.action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly type existingInfrastructure entries', () => {
|
||||||
|
const dbTask = createDatabaseTaskRow();
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
const infra: ExistingInfrastructure[] | undefined =
|
||||||
|
task.existingInfrastructure;
|
||||||
|
expect(infra).toBeDefined();
|
||||||
|
|
||||||
|
if (infra) {
|
||||||
|
const firstInfra: ExistingInfrastructure = infra[0];
|
||||||
|
expect(firstInfra.name).toBe('UserRepository');
|
||||||
|
expect(firstInfra.location).toBe('src/users/user.repository.ts');
|
||||||
|
expect(firstInfra.usage).toContain('user lookups');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly type category field', () => {
|
||||||
|
const dbTask = createDatabaseTaskRow();
|
||||||
|
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||||
|
|
||||||
|
// Verify category is a valid enum value
|
||||||
|
const validCategories = [
|
||||||
|
'research',
|
||||||
|
'design',
|
||||||
|
'development',
|
||||||
|
'testing',
|
||||||
|
'documentation',
|
||||||
|
'review'
|
||||||
|
];
|
||||||
|
expect(task.category).toBeDefined();
|
||||||
|
expect(validCategories).toContain(task.category);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user