refactor(tm-core): migrate to Vitest and Biome, implement clean architecture

- Migrated from Jest to Vitest for faster test execution (~4.2s vs ~4.6-5s)
- Replaced ESLint and Prettier with Biome for unified, faster linting/formatting
- Implemented BaseProvider with Template Method pattern following clean code principles
- Created TaskEntity with business logic encapsulation
- Added TaskMasterCore facade as main entry point
- Implemented complete end-to-end listTasks functionality
- All 50 tests passing with improved performance

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-08-20 23:32:09 +02:00
parent aee1996dc2
commit cf6533207f
31 changed files with 3247 additions and 4310 deletions

View File

@@ -1,45 +0,0 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
env: {
node: true,
es2022: true
},
extends: ['eslint:recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
rules: {
// General code quality
'no-console': 'warn',
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
'no-duplicate-imports': 'error',
// TypeScript specific rules (basic)
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
},
ignorePatterns: [
'dist/',
'node_modules/',
'coverage/',
'*.js',
'!.eslintrc.cjs'
],
overrides: [
{
files: ['**/*.test.ts', '**/*.spec.ts'],
env: {
jest: true
},
rules: {
'no-console': 'off'
}
}
]
};

View File

@@ -1,14 +0,0 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"bracketSameLine": false,
"proseWrap": "preserve"
}

View File

@@ -0,0 +1,98 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"files": {
"include": ["src/**/*.ts", "tests/**/*.ts", "*.ts", "*.js", "*.json"],
"ignore": ["**/node_modules", "**/dist", "**/.git", "**/coverage", "**/*.d.ts"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off",
"useConst": "error",
"useImportType": "warn",
"useTemplate": "warn",
"noUselessElse": "warn",
"noVar": "error"
},
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn"
},
"complexity": {
"noBannedTypes": "error",
"noForEach": "off",
"noStaticOnlyClass": "warn",
"noUselessConstructor": "error",
"noUselessTypeConstraint": "error",
"useArrowFunction": "off"
},
"suspicious": {
"noExplicitAny": "warn",
"noImplicitAnyLet": "error",
"noArrayIndexKey": "warn",
"noAsyncPromiseExecutor": "error",
"noDoubleEquals": "warn",
"noRedundantUseStrict": "error"
},
"security": {
"noGlobalEval": "error"
},
"performance": {
"noAccumulatingSpread": "warn",
"noDelete": "warn"
},
"a11y": {
"recommended": false
}
}
},
"javascript": {
"formatter": {
"enabled": true,
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "none",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false
},
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
},
"json": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100,
"trailingCommas": "none"
},
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
}
}

View File

@@ -1,50 +0,0 @@
/** @type {import('jest').Config} */
export default {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts'],
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: [
'**/tests/**/*.test.ts',
'**/tests/**/*.spec.ts',
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
tsconfig: {
module: 'ESNext',
target: 'ES2022'
}
}
]
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@/types/(.*)$': '<rootDir>/src/types/$1',
'^@/providers/(.*)$': '<rootDir>/src/providers/$1',
'^@/storage/(.*)$': '<rootDir>/src/storage/$1',
'^@/parser/(.*)$': '<rootDir>/src/parser/$1',
'^@/utils/(.*)$': '<rootDir>/src/utils/$1',
'^@/errors/(.*)$': '<rootDir>/src/errors/$1'
},
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
testTimeout: 10000,
verbose: true,
clearMocks: true,
restoreMocks: true
};

View File

@@ -1,5 +1,5 @@
{
"name": "@task-master/tm-core",
"name": "@tm/core",
"version": "1.0.0",
"description": "Core library for Task Master - TypeScript task management system",
"type": "module",
@@ -46,28 +46,27 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "biome check --write",
"lint:check": "biome check",
"lint:fix": "biome check --fix --unsafe",
"format": "biome format --write",
"format:check": "biome format",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@biomejs/biome": "^1.9.4",
"@types/node": "^20.11.30",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"@vitest/coverage-v8": "^2.0.5",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"typescript": "^5.4.3"
"typescript": "^5.4.3",
"vitest": "^2.0.5"
},
"engines": {
"node": ">=18.0.0"

View File

@@ -3,7 +3,6 @@
*/
import { z } from 'zod';
import { TaskPriority, TaskComplexity } from '../types/index.js';
// ============================================================================
// Enum Schemas
@@ -17,12 +16,7 @@ export const taskPrioritySchema = z.enum(['low', 'medium', 'high', 'critical']);
/**
* Task complexity validation schema
*/
export const taskComplexitySchema = z.enum([
'simple',
'moderate',
'complex',
'very-complex'
]);
export const taskComplexitySchema = z.enum(['simple', 'moderate', 'complex', 'very-complex']);
/**
* Log level validation schema
@@ -37,11 +31,7 @@ export const storageTypeSchema = z.enum(['file', 'memory', 'database']);
/**
* Tag naming convention validation schema
*/
export const tagNamingConventionSchema = z.enum([
'kebab-case',
'camelCase',
'snake_case'
]);
export const tagNamingConventionSchema = z.enum(['kebab-case', 'camelCase', 'snake_case']);
/**
* Buffer encoding validation schema
@@ -233,6 +223,4 @@ export const cacheConfigSchema = z
// ============================================================================
export type ConfigurationSchema = z.infer<typeof configurationSchema>;
export type PartialConfigurationSchema = z.infer<
typeof partialConfigurationSchema
>;
export type PartialConfigurationSchema = z.infer<typeof partialConfigurationSchema>;

View File

@@ -0,0 +1,239 @@
/**
* @fileoverview Task entity with business rules and domain logic
*/
import { ERROR_CODES, TaskMasterError } from '../../errors/task-master-error.js';
import type { Subtask, Task, TaskPriority, TaskStatus } from '../../types/index.js';
/**
* Task entity representing a task with business logic
* Encapsulates validation and state management rules
*/
export class TaskEntity implements Task {
readonly id: string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
dependencies: string[];
details: string;
testStrategy: string;
subtasks: Subtask[];
// Optional properties
createdAt?: string;
updatedAt?: string;
effort?: number;
actualEffort?: number;
tags?: string[];
assignee?: string;
complexity?: Task['complexity'];
constructor(data: Task) {
this.validate(data);
this.id = data.id;
this.title = data.title;
this.description = data.description;
this.status = data.status;
this.priority = data.priority;
this.dependencies = data.dependencies || [];
this.details = data.details;
this.testStrategy = data.testStrategy;
this.subtasks = data.subtasks || [];
// Optional properties
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.effort = data.effort;
this.actualEffort = data.actualEffort;
this.tags = data.tags;
this.assignee = data.assignee;
this.complexity = data.complexity;
}
/**
* Validate task data
*/
private validate(data: Partial<Task>): void {
if (!data.id || typeof data.id !== 'string') {
throw new TaskMasterError(
'Task ID is required and must be a string',
ERROR_CODES.VALIDATION_ERROR
);
}
if (!data.title || data.title.trim().length === 0) {
throw new TaskMasterError('Task title is required', ERROR_CODES.VALIDATION_ERROR);
}
if (!data.description || data.description.trim().length === 0) {
throw new TaskMasterError('Task description is required', ERROR_CODES.VALIDATION_ERROR);
}
if (!this.isValidStatus(data.status)) {
throw new TaskMasterError(
`Invalid task status: ${data.status}`,
ERROR_CODES.VALIDATION_ERROR
);
}
if (!this.isValidPriority(data.priority)) {
throw new TaskMasterError(
`Invalid task priority: ${data.priority}`,
ERROR_CODES.VALIDATION_ERROR
);
}
}
/**
* Check if status is valid
*/
private isValidStatus(status: any): status is TaskStatus {
return [
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
].includes(status);
}
/**
* Check if priority is valid
*/
private isValidPriority(priority: any): priority is TaskPriority {
return ['low', 'medium', 'high', 'critical'].includes(priority);
}
/**
* Check if task can be marked as complete
*/
canComplete(): boolean {
// Cannot complete if status is already done or cancelled
if (this.status === 'done' || this.status === 'cancelled') {
return false;
}
// Cannot complete if blocked
if (this.status === 'blocked') {
return false;
}
// Check if all subtasks are complete
const allSubtasksComplete = this.subtasks.every(
(subtask) => subtask.status === 'done' || subtask.status === 'cancelled'
);
return allSubtasksComplete;
}
/**
* Mark task as complete
*/
markAsComplete(): void {
if (!this.canComplete()) {
throw new TaskMasterError(
'Task cannot be marked as complete',
ERROR_CODES.TASK_STATUS_ERROR,
{
taskId: this.id,
currentStatus: this.status,
hasIncompleteSubtasks: this.subtasks.some(
(s) => s.status !== 'done' && s.status !== 'cancelled'
)
}
);
}
this.status = 'done';
this.updatedAt = new Date().toISOString();
}
/**
* Check if task has dependencies
*/
hasDependencies(): boolean {
return this.dependencies.length > 0;
}
/**
* Check if task has subtasks
*/
hasSubtasks(): boolean {
return this.subtasks.length > 0;
}
/**
* Add a subtask
*/
addSubtask(subtask: Omit<Subtask, 'id' | 'parentId'>): void {
const nextId = this.subtasks.length + 1;
this.subtasks.push({
...subtask,
id: nextId,
parentId: this.id
});
this.updatedAt = new Date().toISOString();
}
/**
* Update task status
*/
updateStatus(newStatus: TaskStatus): void {
if (!this.isValidStatus(newStatus)) {
throw new TaskMasterError(`Invalid status: ${newStatus}`, ERROR_CODES.VALIDATION_ERROR);
}
// Business rule: Cannot move from done to pending
if (this.status === 'done' && newStatus === 'pending') {
throw new TaskMasterError(
'Cannot move completed task back to pending',
ERROR_CODES.TASK_STATUS_ERROR
);
}
this.status = newStatus;
this.updatedAt = new Date().toISOString();
}
/**
* Convert entity to plain object
*/
toJSON(): Task {
return {
id: this.id,
title: this.title,
description: this.description,
status: this.status,
priority: this.priority,
dependencies: this.dependencies,
details: this.details,
testStrategy: this.testStrategy,
subtasks: this.subtasks,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
effort: this.effort,
actualEffort: this.actualEffort,
tags: this.tags,
assignee: this.assignee,
complexity: this.complexity
};
}
/**
* Create TaskEntity from plain object
*/
static fromObject(data: Task): TaskEntity {
return new TaskEntity(data);
}
/**
* Create multiple TaskEntities from array
*/
static fromArray(data: Task[]): TaskEntity[] {
return data.map((task) => new TaskEntity(task));
}
}

View File

@@ -174,8 +174,8 @@ export class TaskMasterError extends Error {
}
// If we have a cause error, append its stack trace
if (cause && cause.stack) {
this.stack = this.stack + '\nCaused by: ' + cause.stack;
if (cause?.stack) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
@@ -211,14 +211,7 @@ export class TaskMasterError extends Error {
private containsSensitiveInfo(obj: any): boolean {
if (typeof obj !== 'object' || obj === null) return false;
const sensitiveKeys = [
'password',
'token',
'key',
'secret',
'auth',
'credential'
];
const sensitiveKeys = ['password', 'token', 'key', 'secret', 'auth', 'credential'];
const objString = JSON.stringify(obj).toLowerCase();
return sensitiveKeys.some((key) => objString.includes(key));
@@ -300,9 +293,7 @@ export class TaskMasterError extends Error {
/**
* Create a new error with additional context
*/
public withContext(
additionalContext: Partial<ErrorContext>
): TaskMasterError {
public withContext(additionalContext: Partial<ErrorContext>): TaskMasterError {
return new TaskMasterError(
this.message,
this.code,

View File

@@ -3,6 +3,14 @@
* This file exports all public APIs from the core Task Master library
*/
// Export main facade
export {
TaskMasterCore,
createTaskMasterCore,
type TaskMasterCoreOptions,
type ListTasksResult
} from './task-master-core.js';
// Re-export types
export type * from './types/index';
@@ -25,6 +33,9 @@ export * from './utils/index';
// Re-export errors
export * from './errors/index';
// Re-export entities
export { TaskEntity } from './core/entities/task.entity.js';
// Package metadata
export const version = '1.0.0';
export const name = '@task-master/tm-core';

View File

@@ -282,10 +282,7 @@ export abstract class BaseAIProvider implements IAIProvider {
}
// Abstract methods that must be implemented by concrete classes
abstract generateCompletion(
prompt: string,
options?: AIOptions
): Promise<AIResponse>;
abstract generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse>;
abstract generateStreamingCompletion(
prompt: string,
options?: AIOptions
@@ -309,9 +306,7 @@ export abstract class BaseAIProvider implements IAIProvider {
const modelExists = availableModels.some((m) => m.id === model);
if (!modelExists) {
throw new Error(
`Model "${model}" is not available for provider "${this.getName()}"`
);
throw new Error(`Model "${model}" is not available for provider "${this.getName()}"`);
}
this.currentModel = model;
@@ -347,11 +342,7 @@ export abstract class BaseAIProvider implements IAIProvider {
* @param duration - Request duration in milliseconds
* @param success - Whether the request was successful
*/
protected updateUsageStats(
response: AIResponse,
duration: number,
success: boolean
): void {
protected updateUsageStats(response: AIResponse, duration: number, success: boolean): void {
if (!this.usageStats) return;
this.usageStats.totalRequests++;
@@ -370,18 +361,15 @@ export abstract class BaseAIProvider implements IAIProvider {
}
// Update average response time
const totalTime =
this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
this.usageStats.averageResponseTime =
(totalTime + duration) / this.usageStats.totalRequests;
const totalTime = this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
this.usageStats.averageResponseTime = (totalTime + duration) / this.usageStats.totalRequests;
// Update success rate
const successCount = Math.floor(
this.usageStats.successRate * (this.usageStats.totalRequests - 1)
);
const newSuccessCount = successCount + (success ? 1 : 0);
this.usageStats.successRate =
newSuccessCount / this.usageStats.totalRequests;
this.usageStats.successRate = newSuccessCount / this.usageStats.totalRequests;
this.usageStats.lastRequestAt = new Date().toISOString();
}

View File

@@ -3,7 +3,7 @@
* This file defines the contract for configuration management
*/
import type { TaskPriority, TaskComplexity } from '../types/index';
import type { TaskComplexity, TaskPriority } from '../types/index';
/**
* Model configuration for different AI roles

View File

@@ -40,11 +40,7 @@ export interface IStorage {
* @param tag - Optional tag context for the task
* @returns Promise that resolves when update is complete
*/
updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void>;
updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void>;
/**
* Delete a task by ID
@@ -177,11 +173,7 @@ export abstract class BaseStorage implements IStorage {
abstract loadTasks(tag?: string): Promise<Task[]>;
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;
abstract updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void>;
abstract updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void>;
abstract deleteTask(taskId: string, tag?: string): Promise<void>;
abstract exists(tag?: string): Promise<boolean>;
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;

View File

@@ -22,9 +22,7 @@ export interface TaskParser {
export class PlaceholderParser implements TaskParser {
async parse(content: string): Promise<PlaceholderTask[]> {
// Simple placeholder parsing logic
const lines = content
.split('\n')
.filter((line) => line.trim().startsWith('-'));
const lines = content.split('\n').filter((line) => line.trim().startsWith('-'));
return lines.map((line, index) => ({
id: `task-${index + 1}`,
title: line.trim().replace(/^-\s*/, ''),

View File

@@ -0,0 +1,405 @@
/**
* @fileoverview Abstract base provider with Template Method pattern for AI providers
* Provides common functionality, error handling, and retry logic
*/
import { ERROR_CODES, TaskMasterError } from '../../errors/task-master-error.js';
import type { AIOptions, AIResponse, IAIProvider } from '../../interfaces/ai-provider.interface.js';
// Constants for retry logic
const DEFAULT_MAX_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 1000;
const MAX_RETRY_DELAY_MS = 32000;
const BACKOFF_MULTIPLIER = 2;
const JITTER_FACTOR = 0.1;
// Constants for validation
const MIN_PROMPT_LENGTH = 1;
const MAX_PROMPT_LENGTH = 100000;
const MIN_TEMPERATURE = 0;
const MAX_TEMPERATURE = 2;
const MIN_MAX_TOKENS = 1;
const MAX_MAX_TOKENS = 100000;
/**
* Configuration for BaseProvider
*/
export interface BaseProviderConfig {
apiKey: string;
model?: string;
}
/**
* Internal completion result structure
*/
export interface CompletionResult {
content: string;
inputTokens?: number;
outputTokens?: number;
finishReason?: string;
model?: string;
}
/**
* Validation result for input validation
*/
interface ValidationResult {
valid: boolean;
error?: string;
}
/**
* Prepared request after preprocessing
*/
interface PreparedRequest {
prompt: string;
options: AIOptions;
metadata: Record<string, any>;
}
/**
* Abstract base provider implementing Template Method pattern
* Provides common error handling, retry logic, and validation
*/
export abstract class BaseProvider implements IAIProvider {
protected readonly apiKey: string;
protected model: string;
constructor(config: BaseProviderConfig) {
if (!config.apiKey) {
throw new TaskMasterError('API key is required', ERROR_CODES.AUTHENTICATION_ERROR);
}
this.apiKey = config.apiKey;
this.model = config.model || this.getDefaultModel();
}
/**
* Template method for generating completions
* Handles validation, retries, and error handling
*/
async generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse> {
// Validate input
const validation = this.validateInput(prompt, options);
if (!validation.valid) {
throw new TaskMasterError(validation.error || 'Invalid input', ERROR_CODES.VALIDATION_ERROR);
}
// Prepare request
const prepared = this.prepareRequest(prompt, options);
// Execute with retry logic
let lastError: Error | undefined;
const maxRetries = this.getMaxRetries();
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const startTime = Date.now();
const result = await this.generateCompletionInternal(prepared.prompt, prepared.options);
const duration = Date.now() - startTime;
return this.handleResponse(result, duration, prepared);
} catch (error) {
lastError = error as Error;
if (!this.shouldRetry(error, attempt)) {
break;
}
const delay = this.calculateBackoffDelay(attempt);
await this.sleep(delay);
}
}
// All retries failed
this.handleError(lastError || new Error('Unknown error'));
}
/**
* Validate input prompt and options
*/
protected validateInput(prompt: string, options?: AIOptions): ValidationResult {
// Validate prompt
if (!prompt || typeof prompt !== 'string') {
return { valid: false, error: 'Prompt must be a non-empty string' };
}
const trimmedPrompt = prompt.trim();
if (trimmedPrompt.length < MIN_PROMPT_LENGTH) {
return { valid: false, error: 'Prompt cannot be empty' };
}
if (trimmedPrompt.length > MAX_PROMPT_LENGTH) {
return {
valid: false,
error: `Prompt exceeds maximum length of ${MAX_PROMPT_LENGTH} characters`
};
}
// Validate options if provided
if (options) {
const optionValidation = this.validateOptions(options);
if (!optionValidation.valid) {
return optionValidation;
}
}
return { valid: true };
}
/**
* Validate completion options
*/
protected validateOptions(options: AIOptions): ValidationResult {
if (options.temperature !== undefined) {
if (options.temperature < MIN_TEMPERATURE || options.temperature > MAX_TEMPERATURE) {
return {
valid: false,
error: `Temperature must be between ${MIN_TEMPERATURE} and ${MAX_TEMPERATURE}`
};
}
}
if (options.maxTokens !== undefined) {
if (options.maxTokens < MIN_MAX_TOKENS || options.maxTokens > MAX_MAX_TOKENS) {
return {
valid: false,
error: `Max tokens must be between ${MIN_MAX_TOKENS} and ${MAX_MAX_TOKENS}`
};
}
}
if (options.topP !== undefined) {
if (options.topP < 0 || options.topP > 1) {
return { valid: false, error: 'Top-p must be between 0 and 1' };
}
}
return { valid: true };
}
/**
* Prepare request for processing
*/
protected prepareRequest(prompt: string, options?: AIOptions): PreparedRequest {
const defaultOptions = this.getDefaultOptions();
const mergedOptions = { ...defaultOptions, ...options };
return {
prompt: prompt.trim(),
options: mergedOptions,
metadata: {
provider: this.getName(),
model: this.model,
timestamp: new Date().toISOString()
}
};
}
/**
* Process and format the response
*/
protected handleResponse(
result: CompletionResult,
duration: number,
request: PreparedRequest
): AIResponse {
const inputTokens = result.inputTokens || this.calculateTokens(request.prompt);
const outputTokens = result.outputTokens || this.calculateTokens(result.content);
return {
content: result.content,
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
model: result.model || this.model,
provider: this.getName(),
timestamp: request.metadata.timestamp,
duration,
finishReason: result.finishReason
};
}
/**
* Handle errors with proper wrapping
*/
protected handleError(error: unknown): never {
if (error instanceof TaskMasterError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode = this.getErrorCode(error);
throw new TaskMasterError(
`${this.getName()} provider error: ${errorMessage}`,
errorCode,
{
operation: 'generateCompletion',
resource: this.getName(),
details:
error instanceof Error
? {
name: error.name,
stack: error.stack,
model: this.model
}
: { error: String(error), model: this.model }
},
error instanceof Error ? error : undefined
);
}
/**
* Determine if request should be retried
*/
protected shouldRetry(error: unknown, attempt: number): boolean {
if (attempt >= this.getMaxRetries()) {
return false;
}
return this.isRetryableError(error);
}
/**
* Check if error is retryable
*/
protected isRetryableError(error: unknown): boolean {
if (this.isRateLimitError(error)) return true;
if (this.isTimeoutError(error)) return true;
if (this.isNetworkError(error)) return true;
return false;
}
/**
* Check if error is a rate limit error
*/
protected isRateLimitError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes('rate limit') ||
message.includes('too many requests') ||
message.includes('429')
);
}
return false;
}
/**
* Check if error is a timeout error
*/
protected isTimeoutError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('econnreset')
);
}
return false;
}
/**
* Check if error is a network error
*/
protected isNetworkError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes('network') ||
message.includes('enotfound') ||
message.includes('econnrefused')
);
}
return false;
}
/**
* Calculate exponential backoff delay with jitter
*/
protected calculateBackoffDelay(attempt: number): number {
const exponentialDelay = BASE_RETRY_DELAY_MS * BACKOFF_MULTIPLIER ** (attempt - 1);
const clampedDelay = Math.min(exponentialDelay, MAX_RETRY_DELAY_MS);
// Add jitter to prevent thundering herd
const jitter = clampedDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2;
return Math.round(clampedDelay + jitter);
}
/**
* Get error code from error
*/
protected getErrorCode(error: unknown): string {
if (this.isRateLimitError(error)) return ERROR_CODES.API_ERROR;
if (this.isTimeoutError(error)) return ERROR_CODES.NETWORK_ERROR;
if (this.isNetworkError(error)) return ERROR_CODES.NETWORK_ERROR;
if (error instanceof Error && error.message.includes('401')) {
return ERROR_CODES.AUTHENTICATION_ERROR;
}
return ERROR_CODES.PROVIDER_ERROR;
}
/**
* Sleep utility for delays
*/
protected sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Get default options for completions
*/
protected getDefaultOptions(): AIOptions {
return {
temperature: 0.7,
maxTokens: 2000,
topP: 1.0
};
}
/**
* Get maximum retry attempts
*/
protected getMaxRetries(): number {
return DEFAULT_MAX_RETRIES;
}
// Public interface methods
getModel(): string {
return this.model;
}
setModel(model: string): void {
this.model = model;
}
// Abstract methods that must be implemented by concrete providers
protected abstract generateCompletionInternal(
prompt: string,
options?: AIOptions
): Promise<CompletionResult>;
abstract calculateTokens(text: string, model?: string): number;
abstract getName(): string;
abstract getDefaultModel(): string;
// IAIProvider methods that must be implemented
abstract generateStreamingCompletion(
prompt: string,
options?: AIOptions
): AsyncIterator<Partial<AIResponse>>;
abstract isAvailable(): Promise<boolean>;
abstract getProviderInfo(): import('../../interfaces/ai-provider.interface.js').ProviderInfo;
abstract getAvailableModels(): import('../../interfaces/ai-provider.interface.js').AIModel[];
abstract validateCredentials(): Promise<boolean>;
abstract getUsageStats(): Promise<
import('../../interfaces/ai-provider.interface.js').ProviderUsageStats | null
>;
abstract initialize(): Promise<void>;
abstract close(): Promise<void>;
}

View File

@@ -0,0 +1,14 @@
/**
* @fileoverview Barrel export for AI provider modules
*/
export { BaseProvider } from './base-provider.js';
export type { BaseProviderConfig, CompletionResult } from './base-provider.js';
// Export provider factory when implemented
// export { ProviderFactory } from './provider-factory.js';
// Export concrete providers when implemented
// export { AnthropicProvider } from './adapters/anthropic-provider.js';
// export { OpenAIProvider } from './adapters/openai-provider.js';
// export { GoogleProvider } from './adapters/google-provider.js';

View File

@@ -3,11 +3,11 @@
* Provides common functionality and properties for all AI provider implementations
*/
import {
IAIProvider,
import type {
AIModel,
AIOptions,
AIResponse,
AIModel,
IAIProvider,
ProviderInfo,
ProviderUsageStats
} from '../interfaces/ai-provider.interface.js';
@@ -32,9 +32,9 @@ export abstract class BaseProvider implements IAIProvider {
/** Current model being used */
protected model: string;
/** Maximum number of retry attempts */
protected maxRetries: number = 3;
protected maxRetries = 3;
/** Delay between retries in milliseconds */
protected retryDelay: number = 1000;
protected retryDelay = 1000;
/**
* Constructor for BaseProvider
@@ -54,10 +54,7 @@ export abstract class BaseProvider implements IAIProvider {
}
// Abstract methods that concrete providers must implement
abstract generateCompletion(
prompt: string,
options?: AIOptions
): Promise<AIResponse>;
abstract generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse>;
abstract generateStreamingCompletion(
prompt: string,
options?: AIOptions

View File

@@ -1,27 +1,19 @@
/**
* @fileoverview AI provider implementations for the tm-core package
* This file exports all AI provider classes and interfaces
* @fileoverview Barrel export for provider modules
*/
// Provider interfaces and implementations
export * from './base-provider.js';
// export * from './anthropic-provider.js';
// export * from './openai-provider.js';
// export * from './perplexity-provider.js';
// Export AI providers from subdirectory
export { BaseProvider } from './ai/base-provider.js';
export type {
BaseProviderConfig,
CompletionResult
} from './ai/base-provider.js';
// Placeholder exports - these will be implemented in later tasks
export interface AIProvider {
name: string;
generateResponse(prompt: string): Promise<string>;
}
// Export all from AI module
export * from './ai/index.js';
/**
* @deprecated This is a placeholder class that will be properly implemented in later tasks
*/
export class PlaceholderProvider implements AIProvider {
name = 'placeholder';
// Storage providers will be exported here when implemented
// export * from './storage/index.js';
async generateResponse(prompt: string): Promise<string> {
return `Placeholder response for: ${prompt}`;
}
}
// Placeholder provider for tests
export { PlaceholderProvider } from './placeholder-provider.js';

View File

@@ -0,0 +1,15 @@
/**
* @fileoverview Placeholder provider for testing purposes
* @deprecated This is a placeholder implementation that will be replaced
*/
/**
* PlaceholderProvider for smoke tests
*/
export class PlaceholderProvider {
name = 'placeholder';
async generateResponse(prompt: string): Promise<string> {
return `Mock response to: ${prompt}`;
}
}

View File

@@ -2,8 +2,8 @@
* File-based storage implementation for Task Master
*/
import { promises as fs } from 'fs';
import path from 'path';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Task, TaskMetadata } from '../types/index.js';
import { BaseStorage, type StorageStats } from './storage.interface.js';
@@ -228,9 +228,7 @@ export class FileStorage extends BaseStorage {
/**
* Read and parse JSON file with error handling
*/
private async readJsonFile(
filePath: string
): Promise<FileStorageData | null> {
private async readJsonFile(filePath: string): Promise<FileStorageData | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
@@ -248,10 +246,7 @@ export class FileStorage extends BaseStorage {
/**
* Write JSON file with atomic operation using temp file
*/
private async writeJsonFile(
filePath: string,
data: FileStorageData
): Promise<void> {
private async writeJsonFile(filePath: string, data: FileStorageData): Promise<void> {
// Use file locking to prevent concurrent writes
const lockKey = filePath;
const existingLock = this.fileLocks.get(lockKey);
@@ -273,10 +268,7 @@ export class FileStorage extends BaseStorage {
/**
* Perform the actual write operation
*/
private async performWrite(
filePath: string,
data: FileStorageData
): Promise<void> {
private async performWrite(filePath: string, data: FileStorageData): Promise<void> {
const tempPath = `${filePath}.tmp`;
try {
@@ -331,9 +323,7 @@ export class FileStorage extends BaseStorage {
try {
const files = await fs.readdir(dir);
const backupFiles = files
.filter(
(f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json')
)
.filter((f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json'))
.sort()
.reverse();

View File

@@ -2,12 +2,7 @@
* Storage interface and base implementation for Task Master
*/
import type {
Task,
TaskMetadata,
TaskFilter,
TaskSortOptions
} from '../types/index.js';
import type { Task, TaskFilter, TaskMetadata, TaskSortOptions } from '../types/index.js';
/**
* Storage statistics
@@ -38,11 +33,7 @@ export interface IStorage {
loadTasks(tag?: string): Promise<Task[]>;
saveTasks(tasks: Task[], tag?: string): Promise<void>;
appendTasks(tasks: Task[], tag?: string): Promise<void>;
updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<boolean>;
updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<boolean>;
deleteTask(taskId: string, tag?: string): Promise<boolean>;
exists(tag?: string): Promise<boolean>;
@@ -100,11 +91,7 @@ export abstract class BaseStorage implements IStorage {
await this.saveTasks(mergedTasks, tag);
}
async updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<boolean> {
async updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<boolean> {
const tasks = await this.loadTasks(tag);
const taskIndex = tasks.findIndex((t) => t.id === taskId);
@@ -149,7 +136,7 @@ export abstract class BaseStorage implements IStorage {
};
}
async saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void> {
async saveMetadata(_metadata: TaskMetadata, _tag?: string): Promise<void> {
// Default implementation: metadata is derived from tasks
// Subclasses can override if they store metadata separately
}
@@ -189,26 +176,19 @@ export abstract class BaseStorage implements IStorage {
return tasks.filter((task) => {
// Status filter
if (filter.status) {
const statuses = Array.isArray(filter.status)
? filter.status
: [filter.status];
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
if (!statuses.includes(task.status)) return false;
}
// Priority filter
if (filter.priority) {
const priorities = Array.isArray(filter.priority)
? filter.priority
: [filter.priority];
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
if (!priorities.includes(task.priority)) return false;
}
// Tags filter
if (filter.tags && filter.tags.length > 0) {
if (
!task.tags ||
!filter.tags.some((tag) => task.tags?.includes(tag))
) {
if (!task.tags || !filter.tags.some((tag) => task.tags?.includes(tag))) {
return false;
}
}
@@ -223,9 +203,7 @@ export abstract class BaseStorage implements IStorage {
if (filter.search) {
const searchLower = filter.search.toLowerCase();
const inTitle = task.title.toLowerCase().includes(searchLower);
const inDescription = task.description
.toLowerCase()
.includes(searchLower);
const inDescription = task.description.toLowerCase().includes(searchLower);
const inDetails = task.details.toLowerCase().includes(searchLower);
if (!inTitle && !inDescription && !inDetails) return false;
}
@@ -240,8 +218,7 @@ export abstract class BaseStorage implements IStorage {
const complexities = Array.isArray(filter.complexity)
? filter.complexity
: [filter.complexity];
if (!task.complexity || !complexities.includes(task.complexity))
return false;
if (!task.complexity || !complexities.includes(task.complexity)) return false;
}
return true;

View File

@@ -0,0 +1,302 @@
/**
* @fileoverview TaskMasterCore facade - main entry point for tm-core functionality
*/
import { TaskEntity } from './core/entities/task.entity.js';
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
import type { IConfiguration } from './interfaces/configuration.interface.js';
import type { IStorage } from './interfaces/storage.interface.js';
import { FileStorage } from './storage/file-storage.js';
import type { Task, TaskFilter, TaskStatus } from './types/index.js';
/**
* Options for creating TaskMasterCore instance
*/
export interface TaskMasterCoreOptions {
projectPath: string;
configuration?: Partial<IConfiguration>;
storage?: IStorage;
}
/**
* List tasks result with metadata
*/
export interface ListTasksResult {
tasks: Task[];
total: number;
filtered: number;
tag?: string;
}
/**
* TaskMasterCore facade class
* Provides simplified API for all tm-core operations
*/
export class TaskMasterCore {
private storage: IStorage;
private projectPath: string;
private configuration: Partial<IConfiguration>;
private initialized = false;
constructor(options: TaskMasterCoreOptions) {
if (!options.projectPath) {
throw new TaskMasterError('Project path is required', ERROR_CODES.MISSING_CONFIGURATION);
}
this.projectPath = options.projectPath;
this.configuration = options.configuration || {};
// Use provided storage or create default FileStorage
this.storage = options.storage || new FileStorage(this.projectPath);
}
/**
* Initialize the TaskMasterCore instance
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
await this.storage.initialize();
this.initialized = true;
} catch (error) {
throw new TaskMasterError(
'Failed to initialize TaskMasterCore',
ERROR_CODES.INTERNAL_ERROR,
{ operation: 'initialize' },
error as Error
);
}
}
/**
* Ensure the instance is initialized
*/
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
/**
* List all tasks with optional filtering
*/
async listTasks(options?: {
tag?: string;
filter?: TaskFilter;
includeSubtasks?: boolean;
}): Promise<ListTasksResult> {
await this.ensureInitialized();
try {
// Load tasks from storage
const rawTasks = await this.storage.loadTasks(options?.tag);
// Convert to TaskEntity for business logic
const taskEntities = TaskEntity.fromArray(rawTasks);
// Apply filters if provided
let filteredTasks = taskEntities;
if (options?.filter) {
filteredTasks = this.applyFilters(taskEntities, options.filter);
}
// Convert back to plain objects
const tasks = filteredTasks.map((entity) => entity.toJSON());
// Optionally exclude subtasks
const finalTasks =
options?.includeSubtasks === false
? tasks.map((task) => ({ ...task, subtasks: [] }))
: tasks;
return {
tasks: finalTasks,
total: rawTasks.length,
filtered: filteredTasks.length,
tag: options?.tag
};
} catch (error) {
throw new TaskMasterError(
'Failed to list tasks',
ERROR_CODES.INTERNAL_ERROR,
{
operation: 'listTasks',
tag: options?.tag
},
error as Error
);
}
}
/**
* Get a specific task by ID
*/
async getTask(taskId: string, tag?: string): Promise<Task | null> {
await this.ensureInitialized();
const result = await this.listTasks({ tag });
const task = result.tasks.find((t) => t.id === taskId);
return task || null;
}
/**
* Get tasks by status
*/
async getTasksByStatus(status: TaskStatus | TaskStatus[], tag?: string): Promise<Task[]> {
const statuses = Array.isArray(status) ? status : [status];
const result = await this.listTasks({
tag,
filter: { status: statuses }
});
return result.tasks;
}
/**
* Get task statistics
*/
async getTaskStats(tag?: string): Promise<{
total: number;
byStatus: Record<TaskStatus, number>;
withSubtasks: number;
blocked: number;
}> {
const result = await this.listTasks({ tag });
const stats = {
total: result.total,
byStatus: {} as Record<TaskStatus, number>,
withSubtasks: 0,
blocked: 0
};
// Initialize status counts
const statuses: TaskStatus[] = [
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
];
statuses.forEach((status) => {
stats.byStatus[status] = 0;
});
// Count tasks
result.tasks.forEach((task) => {
stats.byStatus[task.status]++;
if (task.subtasks && task.subtasks.length > 0) {
stats.withSubtasks++;
}
if (task.status === 'blocked') {
stats.blocked++;
}
});
return stats;
}
/**
* Apply filters to tasks
*/
private applyFilters(tasks: TaskEntity[], filter: TaskFilter): TaskEntity[] {
return tasks.filter((task) => {
// Filter by status
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
if (!statuses.includes(task.status)) {
return false;
}
}
// Filter by priority
if (filter.priority) {
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
if (!priorities.includes(task.priority)) {
return false;
}
}
// Filter by tags
if (filter.tags && filter.tags.length > 0) {
if (!task.tags || !filter.tags.some((tag) => task.tags?.includes(tag))) {
return false;
}
}
// Filter by assignee
if (filter.assignee) {
if (task.assignee !== filter.assignee) {
return false;
}
}
// Filter by complexity
if (filter.complexity) {
const complexities = Array.isArray(filter.complexity)
? filter.complexity
: [filter.complexity];
if (!task.complexity || !complexities.includes(task.complexity)) {
return false;
}
}
// Filter by search term
if (filter.search) {
const searchLower = filter.search.toLowerCase();
const inTitle = task.title.toLowerCase().includes(searchLower);
const inDescription = task.description.toLowerCase().includes(searchLower);
const inDetails = task.details.toLowerCase().includes(searchLower);
if (!inTitle && !inDescription && !inDetails) {
return false;
}
}
// Filter by hasSubtasks
if (filter.hasSubtasks !== undefined) {
const hasSubtasks = task.subtasks.length > 0;
if (hasSubtasks !== filter.hasSubtasks) {
return false;
}
}
return true;
});
}
/**
* Close and cleanup resources
*/
async close(): Promise<void> {
if (this.storage) {
await this.storage.close();
}
this.initialized = false;
}
}
/**
* Factory function to create TaskMasterCore instance
*/
export function createTaskMasterCore(
projectPath: string,
options?: {
configuration?: Partial<IConfiguration>;
storage?: IStorage;
}
): TaskMasterCore {
return new TaskMasterCore({
projectPath,
configuration: options?.configuration,
storage: options?.storage
});
}

View File

@@ -93,10 +93,7 @@ export interface TaskCollection {
/**
* Type for creating a new task (without generated fields)
*/
export type CreateTask = Omit<
Task,
'id' | 'createdAt' | 'updatedAt' | 'subtasks'
> & {
export type CreateTask = Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'subtasks'> & {
subtasks?: Omit<Subtask, 'id' | 'parentId' | 'createdAt' | 'updatedAt'>[];
};
@@ -138,15 +135,7 @@ export interface TaskSortOptions {
export function isTaskStatus(value: unknown): value is TaskStatus {
return (
typeof value === 'string' &&
[
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
].includes(value)
['pending', 'in-progress', 'done', 'deferred', 'cancelled', 'blocked', 'review'].includes(value)
);
}
@@ -154,10 +143,7 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
* Type guard to check if a value is a valid TaskPriority
*/
export function isTaskPriority(value: unknown): value is TaskPriority {
return (
typeof value === 'string' &&
['low', 'medium', 'high', 'critical'].includes(value)
);
return typeof value === 'string' && ['low', 'medium', 'high', 'critical'].includes(value);
}
/**
@@ -165,8 +151,7 @@ export function isTaskPriority(value: unknown): value is TaskPriority {
*/
export function isTaskComplexity(value: unknown): value is TaskComplexity {
return (
typeof value === 'string' &&
['simple', 'moderate', 'complex', 'very-complex'].includes(value)
typeof value === 'string' && ['simple', 'moderate', 'complex', 'very-complex'].includes(value)
);
}

View File

@@ -3,7 +3,7 @@
* Provides functions to generate unique identifiers for tasks and subtasks
*/
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
/**
* Generates a unique task ID using the format: TASK-{timestamp}-{random}
@@ -33,28 +33,22 @@ export function generateTaskId(): string {
* // Returns: "TASK-123-A7B3.2"
* ```
*/
export function generateSubtaskId(
parentId: string,
existingSubtasks: string[] = []
): string {
export function generateSubtaskId(parentId: string, existingSubtasks: string[] = []): string {
// Find existing subtasks for this parent
const parentSubtasks = existingSubtasks.filter((id) =>
id.startsWith(`${parentId}.`)
);
const parentSubtasks = existingSubtasks.filter((id) => id.startsWith(`${parentId}.`));
// Extract sequential numbers and find the highest
const sequentialNumbers = parentSubtasks
.map((id) => {
const parts = id.split('.');
const lastPart = parts[parts.length - 1];
return parseInt(lastPart, 10);
return Number.parseInt(lastPart, 10);
})
.filter((num) => !isNaN(num))
.filter((num) => !Number.isNaN(num))
.sort((a, b) => a - b);
// Determine the next sequential number
const nextSequential =
sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
const nextSequential = sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
return `${parentId}.${nextSequential}`;
}
@@ -118,8 +112,8 @@ export function isValidSubtaskId(id: string): boolean {
// Remaining parts should be positive integers
const sequentialParts = parts.slice(1);
return sequentialParts.every((part) => {
const num = parseInt(part, 10);
return !isNaN(num) && num > 0 && part === num.toString();
const num = Number.parseInt(part, 10);
return !Number.isNaN(num) && num > 0 && part === num.toString();
});
}

View File

@@ -0,0 +1,412 @@
/**
* @fileoverview End-to-end integration test for listTasks functionality
*/
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
type Task,
type TaskMasterCore,
type TaskStatus,
createTaskMasterCore
} from '../../src/index';
describe('TaskMasterCore - listTasks E2E', () => {
let tmpDir: string;
let tmCore: TaskMasterCore;
// Sample tasks data
const sampleTasks: Task[] = [
{
id: '1',
title: 'Setup project',
description: 'Initialize the project structure',
status: 'done',
priority: 'high',
dependencies: [],
details: 'Create all necessary directories and config files',
testStrategy: 'Manual verification',
subtasks: [
{
id: 1,
parentId: '1',
title: 'Create directories',
description: 'Create project directories',
status: 'done',
priority: 'high',
dependencies: [],
details: 'Create src, tests, docs directories',
testStrategy: 'Check directories exist'
},
{
id: 2,
parentId: '1',
title: 'Initialize package.json',
description: 'Create package.json file',
status: 'done',
priority: 'high',
dependencies: [],
details: 'Run npm init',
testStrategy: 'Verify package.json exists'
}
],
tags: ['setup', 'infrastructure']
},
{
id: '2',
title: 'Implement core features',
description: 'Build the main functionality',
status: 'in-progress',
priority: 'high',
dependencies: ['1'],
details: 'Implement all core business logic',
testStrategy: 'Unit tests for all features',
subtasks: [],
tags: ['feature', 'core'],
assignee: 'developer1'
},
{
id: '3',
title: 'Write documentation',
description: 'Create user and developer docs',
status: 'pending',
priority: 'medium',
dependencies: ['2'],
details: 'Write comprehensive documentation',
testStrategy: 'Review by team',
subtasks: [],
tags: ['documentation'],
complexity: 'simple'
},
{
id: '4',
title: 'Performance optimization',
description: 'Optimize for speed and efficiency',
status: 'blocked',
priority: 'low',
dependencies: ['2'],
details: 'Profile and optimize bottlenecks',
testStrategy: 'Performance benchmarks',
subtasks: [],
assignee: 'developer2',
complexity: 'complex'
},
{
id: '5',
title: 'Security audit',
description: 'Review security vulnerabilities',
status: 'deferred',
priority: 'critical',
dependencies: [],
details: 'Complete security assessment',
testStrategy: 'Security scanning tools',
subtasks: [],
tags: ['security', 'audit']
}
];
beforeEach(async () => {
// Create temp directory for testing
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-core-test-'));
// Create .taskmaster/tasks directory
const tasksDir = path.join(tmpDir, '.taskmaster', 'tasks');
await fs.mkdir(tasksDir, { recursive: true });
// Write sample tasks.json
const tasksFile = path.join(tasksDir, 'tasks.json');
const tasksData = {
tasks: sampleTasks,
metadata: {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: sampleTasks.length,
completedCount: 1
}
};
await fs.writeFile(tasksFile, JSON.stringify(tasksData, null, 2));
// Create TaskMasterCore instance
tmCore = createTaskMasterCore(tmpDir);
await tmCore.initialize();
});
afterEach(async () => {
// Cleanup
if (tmCore) {
await tmCore.close();
}
// Remove temp directory
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe('Basic listing', () => {
it('should list all tasks', async () => {
const result = await tmCore.listTasks();
expect(result.tasks).toHaveLength(5);
expect(result.total).toBe(5);
expect(result.filtered).toBe(5);
expect(result.tag).toBeUndefined();
});
it('should include subtasks by default', async () => {
const result = await tmCore.listTasks();
const setupTask = result.tasks.find((t) => t.id === '1');
expect(setupTask?.subtasks).toHaveLength(2);
expect(setupTask?.subtasks[0].title).toBe('Create directories');
});
it('should exclude subtasks when requested', async () => {
const result = await tmCore.listTasks({ includeSubtasks: false });
const setupTask = result.tasks.find((t) => t.id === '1');
expect(setupTask?.subtasks).toHaveLength(0);
});
});
describe('Filtering', () => {
it('should filter by status', async () => {
const result = await tmCore.listTasks({
filter: { status: 'done' }
});
expect(result.filtered).toBe(1);
expect(result.tasks[0].id).toBe('1');
});
it('should filter by multiple statuses', async () => {
const result = await tmCore.listTasks({
filter: { status: ['done', 'in-progress'] }
});
expect(result.filtered).toBe(2);
const ids = result.tasks.map((t) => t.id);
expect(ids).toContain('1');
expect(ids).toContain('2');
});
it('should filter by priority', async () => {
const result = await tmCore.listTasks({
filter: { priority: 'high' }
});
expect(result.filtered).toBe(2);
});
it('should filter by tags', async () => {
const result = await tmCore.listTasks({
filter: { tags: ['setup'] }
});
expect(result.filtered).toBe(1);
expect(result.tasks[0].id).toBe('1');
});
it('should filter by assignee', async () => {
const result = await tmCore.listTasks({
filter: { assignee: 'developer1' }
});
expect(result.filtered).toBe(1);
expect(result.tasks[0].id).toBe('2');
});
it('should filter by complexity', async () => {
const result = await tmCore.listTasks({
filter: { complexity: 'complex' }
});
expect(result.filtered).toBe(1);
expect(result.tasks[0].id).toBe('4');
});
it('should filter by search term', async () => {
const result = await tmCore.listTasks({
filter: { search: 'documentation' }
});
expect(result.filtered).toBe(1);
expect(result.tasks[0].id).toBe('3');
});
it('should filter by hasSubtasks', async () => {
const withSubtasks = await tmCore.listTasks({
filter: { hasSubtasks: true }
});
expect(withSubtasks.filtered).toBe(1);
expect(withSubtasks.tasks[0].id).toBe('1');
const withoutSubtasks = await tmCore.listTasks({
filter: { hasSubtasks: false }
});
expect(withoutSubtasks.filtered).toBe(4);
});
it('should handle combined filters', async () => {
const result = await tmCore.listTasks({
filter: {
priority: ['high', 'critical'],
status: ['pending', 'deferred']
}
});
expect(result.filtered).toBe(1);
expect(result.tasks[0].id).toBe('5'); // Critical priority, deferred status
});
});
describe('Helper methods', () => {
it('should get task by ID', async () => {
const task = await tmCore.getTask('2');
expect(task).not.toBeNull();
expect(task?.title).toBe('Implement core features');
});
it('should return null for non-existent task', async () => {
const task = await tmCore.getTask('999');
expect(task).toBeNull();
});
it('should get tasks by status', async () => {
const pendingTasks = await tmCore.getTasksByStatus('pending');
expect(pendingTasks).toHaveLength(1);
expect(pendingTasks[0].id).toBe('3');
const multipleTasks = await tmCore.getTasksByStatus(['done', 'blocked']);
expect(multipleTasks).toHaveLength(2);
});
it('should get task statistics', async () => {
const stats = await tmCore.getTaskStats();
expect(stats.total).toBe(5);
expect(stats.byStatus.done).toBe(1);
expect(stats.byStatus['in-progress']).toBe(1);
expect(stats.byStatus.pending).toBe(1);
expect(stats.byStatus.blocked).toBe(1);
expect(stats.byStatus.deferred).toBe(1);
expect(stats.byStatus.cancelled).toBe(0);
expect(stats.byStatus.review).toBe(0);
expect(stats.withSubtasks).toBe(1);
expect(stats.blocked).toBe(1);
});
});
describe('Error handling', () => {
it('should handle missing tasks file gracefully', async () => {
// Create new instance with empty directory
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-empty-'));
const emptyCore = createTaskMasterCore(emptyDir);
try {
const result = await emptyCore.listTasks();
expect(result.tasks).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.filtered).toBe(0);
} finally {
await emptyCore.close();
await fs.rm(emptyDir, { recursive: true, force: true });
}
});
it('should validate task entities', async () => {
// Write invalid task data
const invalidDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-invalid-'));
const tasksDir = path.join(invalidDir, '.taskmaster', 'tasks');
await fs.mkdir(tasksDir, { recursive: true });
const invalidData = {
tasks: [
{
id: '', // Invalid: empty ID
title: 'Test',
description: 'Test',
status: 'done',
priority: 'high',
dependencies: [],
details: 'Test',
testStrategy: 'Test',
subtasks: []
}
],
metadata: {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: 1,
completedCount: 0
}
};
await fs.writeFile(path.join(tasksDir, 'tasks.json'), JSON.stringify(invalidData));
const invalidCore = createTaskMasterCore(invalidDir);
try {
await expect(invalidCore.listTasks()).rejects.toThrow();
} finally {
await invalidCore.close();
await fs.rm(invalidDir, { recursive: true, force: true });
}
});
});
describe('Tags support', () => {
beforeEach(async () => {
// Create tasks for a different tag
const taggedTasks = [
{
id: 'tag-1',
title: 'Tagged task',
description: 'Task with tag',
status: 'pending' as TaskStatus,
priority: 'medium' as const,
dependencies: [],
details: 'Tagged task details',
testStrategy: 'Test',
subtasks: []
}
];
const tagFile = path.join(tmpDir, '.taskmaster', 'tasks', 'feature-branch.json');
await fs.writeFile(
tagFile,
JSON.stringify({
tasks: taggedTasks,
metadata: {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: 1,
completedCount: 0
}
})
);
});
it('should list tasks for specific tag', async () => {
const result = await tmCore.listTasks({ tag: 'feature-branch' });
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].id).toBe('tag-1');
expect(result.tag).toBe('feature-branch');
});
it('should list default tasks when no tag specified', async () => {
const result = await tmCore.listTasks();
expect(result.tasks).toHaveLength(5);
expect(result.tasks[0].id).toBe('1');
});
});
});

View File

@@ -0,0 +1,205 @@
/**
* @fileoverview Mock provider for testing BaseProvider functionality
*/
import type {
AIModel,
AIOptions,
AIResponse,
ProviderInfo,
ProviderUsageStats
} from '../../src/interfaces/ai-provider.interface';
import {
BaseProvider,
type BaseProviderConfig,
type CompletionResult
} from '../../src/providers/ai/base-provider';
/**
* Configuration for MockProvider behavior
*/
export interface MockProviderOptions extends BaseProviderConfig {
shouldFail?: boolean;
failAfterAttempts?: number;
simulateRateLimit?: boolean;
simulateTimeout?: boolean;
responseDelay?: number;
tokenMultiplier?: number;
}
/**
* Mock provider for testing BaseProvider functionality
*/
export class MockProvider extends BaseProvider {
private attemptCount = 0;
private readonly options: MockProviderOptions;
constructor(options: MockProviderOptions) {
super(options);
this.options = options;
}
/**
* Simulate completion generation with configurable behavior
*/
protected async generateCompletionInternal(
prompt: string,
_options?: AIOptions
): Promise<CompletionResult> {
this.attemptCount++;
// Simulate delay if configured
if (this.options.responseDelay) {
await this.sleep(this.options.responseDelay);
}
// Simulate failures based on configuration
if (this.options.shouldFail) {
throw new Error('Mock provider error');
}
if (this.options.failAfterAttempts && this.attemptCount <= this.options.failAfterAttempts) {
if (this.options.simulateRateLimit) {
throw new Error('Rate limit exceeded - too many requests (429)');
}
if (this.options.simulateTimeout) {
throw new Error('Request timeout - ECONNRESET');
}
throw new Error('Temporary failure');
}
// Return successful mock response
return {
content: `Mock response to: ${prompt}`,
inputTokens: this.calculateTokens(prompt),
outputTokens: this.calculateTokens(`Mock response to: ${prompt}`),
finishReason: 'complete',
model: this.model
};
}
/**
* Simple token calculation for testing
*/
calculateTokens(text: string, _model?: string): number {
const multiplier = this.options.tokenMultiplier || 1;
// Rough approximation: 1 token per 4 characters
return Math.ceil((text.length / 4) * multiplier);
}
getName(): string {
return 'mock';
}
getDefaultModel(): string {
return 'mock-model-v1';
}
/**
* Get the number of attempts made
*/
getAttemptCount(): number {
return this.attemptCount;
}
/**
* Reset attempt counter
*/
resetAttempts(): void {
this.attemptCount = 0;
}
// Implement remaining abstract methods
async generateStreamingCompletion(
prompt: string,
_options?: AIOptions
): AsyncIterator<Partial<AIResponse>> {
// Simple mock implementation
const response: Partial<AIResponse> = {
content: `Mock streaming response to: ${prompt}`,
provider: this.getName(),
model: this.model
};
return {
async next() {
return { value: response, done: true };
}
};
}
async isAvailable(): Promise<boolean> {
return !this.options.shouldFail;
}
getProviderInfo(): ProviderInfo {
return {
name: 'mock',
displayName: 'Mock Provider',
description: 'Mock provider for testing',
models: this.getAvailableModels(),
defaultModel: this.getDefaultModel(),
requiresApiKey: true,
features: {
streaming: true,
functions: false,
vision: false,
embeddings: false
}
};
}
getAvailableModels(): AIModel[] {
return [
{
id: 'mock-model-v1',
name: 'Mock Model v1',
description: 'First mock model',
contextLength: 4096,
inputCostPer1K: 0.001,
outputCostPer1K: 0.002,
supportsStreaming: true
},
{
id: 'mock-model-v2',
name: 'Mock Model v2',
description: 'Second mock model',
contextLength: 8192,
inputCostPer1K: 0.002,
outputCostPer1K: 0.004,
supportsStreaming: true
}
];
}
async validateCredentials(): Promise<boolean> {
return this.apiKey === 'valid-key';
}
async getUsageStats(): Promise<ProviderUsageStats | null> {
return {
totalRequests: this.attemptCount,
totalTokens: 1000,
totalCost: 0.01,
requestsToday: this.attemptCount,
tokensToday: 1000,
costToday: 0.01,
averageResponseTime: 100,
successRate: 0.9,
lastRequestAt: new Date().toISOString()
};
}
async initialize(): Promise<void> {
// No-op for mock
}
async close(): Promise<void> {
// No-op for mock
}
// Override retry configuration for testing
protected getMaxRetries(): number {
return this.options.failAfterAttempts ? this.options.failAfterAttempts + 1 : 3;
}
}

View File

@@ -1,22 +1,21 @@
/**
* Jest setup file for tm-core package
* This file is executed before running tests and can be used to configure
* testing utilities, global mocks, and test environment setup.
* @fileoverview Vitest test setup file
*/
// Configure test environment
process.env.NODE_ENV = 'test';
import { afterAll, beforeAll, vi } from 'vitest';
// Global test utilities can be added here
// Custom matchers and global types can be defined here in the future
// Setup any global test configuration here
// For example, increase timeout for slow CI environments
if (process.env.CI) {
// Vitest timeout is configured in vitest.config.ts
}
// Set up any global mocks or configurations here
beforeEach(() => {
// Reset any global state before each test
jest.clearAllMocks();
// Suppress console errors during tests unless explicitly testing them
const originalError = console.error;
beforeAll(() => {
console.error = vi.fn();
});
afterEach(() => {
// Clean up after each test
jest.restoreAllMocks();
afterAll(() => {
console.error = originalError;
});

View File

@@ -0,0 +1,258 @@
/**
* @fileoverview Unit tests for BaseProvider abstract class
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { ERROR_CODES, TaskMasterError } from '../../src/errors/task-master-error';
import { MockProvider } from '../mocks/mock-provider';
describe('BaseProvider', () => {
describe('constructor', () => {
it('should require an API key', () => {
expect(() => {
new MockProvider({ apiKey: '' });
}).toThrow(TaskMasterError);
});
it('should initialize with provided API key and model', () => {
const provider = new MockProvider({
apiKey: 'test-key',
model: 'mock-model-v2'
});
expect(provider.getModel()).toBe('mock-model-v2');
});
it('should use default model if not provided', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
expect(provider.getModel()).toBe('mock-model-v1');
});
});
describe('generateCompletion', () => {
let provider: MockProvider;
beforeEach(() => {
provider = new MockProvider({ apiKey: 'test-key' });
});
it('should successfully generate a completion', async () => {
const response = await provider.generateCompletion('Test prompt');
expect(response).toMatchObject({
content: 'Mock response to: Test prompt',
provider: 'mock',
model: 'mock-model-v1',
inputTokens: expect.any(Number),
outputTokens: expect.any(Number),
totalTokens: expect.any(Number),
duration: expect.any(Number),
timestamp: expect.any(String)
});
});
it('should validate empty prompts', async () => {
await expect(provider.generateCompletion('')).rejects.toThrow(
'Prompt must be a non-empty string'
);
});
it('should validate prompt type', async () => {
await expect(provider.generateCompletion(null as any)).rejects.toThrow(
'Prompt must be a non-empty string'
);
});
it('should validate temperature range', async () => {
await expect(provider.generateCompletion('Test', { temperature: 3 })).rejects.toThrow(
'Temperature must be between 0 and 2'
);
});
it('should validate maxTokens range', async () => {
await expect(provider.generateCompletion('Test', { maxTokens: 0 })).rejects.toThrow(
'Max tokens must be between 1 and 100000'
);
});
it('should validate topP range', async () => {
await expect(provider.generateCompletion('Test', { topP: 1.5 })).rejects.toThrow(
'Top-p must be between 0 and 1'
);
});
});
describe('retry logic', () => {
it('should retry on rate limit errors', async () => {
const provider = new MockProvider({
apiKey: 'test-key',
failAfterAttempts: 2,
simulateRateLimit: true,
responseDelay: 10
});
const response = await provider.generateCompletion('Test prompt');
expect(response.content).toBe('Mock response to: Test prompt');
expect(provider.getAttemptCount()).toBe(3); // 2 failures + 1 success
});
it('should retry on timeout errors', async () => {
const provider = new MockProvider({
apiKey: 'test-key',
failAfterAttempts: 1,
simulateTimeout: true
});
const response = await provider.generateCompletion('Test prompt');
expect(response.content).toBe('Mock response to: Test prompt');
expect(provider.getAttemptCount()).toBe(2); // 1 failure + 1 success
});
it('should fail after max retries', async () => {
const provider = new MockProvider({
apiKey: 'test-key',
shouldFail: true
});
await expect(provider.generateCompletion('Test prompt')).rejects.toThrow(
'mock provider error'
);
});
it('should calculate exponential backoff delays', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
// Access protected method through type assertion
const calculateDelay = (provider as any).calculateBackoffDelay.bind(provider);
const delay1 = calculateDelay(1);
const delay2 = calculateDelay(2);
const delay3 = calculateDelay(3);
// Check exponential growth (with jitter, so use ranges)
expect(delay1).toBeGreaterThanOrEqual(900);
expect(delay1).toBeLessThanOrEqual(1100);
expect(delay2).toBeGreaterThanOrEqual(1800);
expect(delay2).toBeLessThanOrEqual(2200);
expect(delay3).toBeGreaterThanOrEqual(3600);
expect(delay3).toBeLessThanOrEqual(4400);
});
});
describe('error handling', () => {
it('should wrap provider errors properly', async () => {
const provider = new MockProvider({
apiKey: 'test-key',
shouldFail: true
});
try {
await provider.generateCompletion('Test prompt');
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(TaskMasterError);
const tmError = error as TaskMasterError;
expect(tmError.code).toBe(ERROR_CODES.PROVIDER_ERROR);
expect(tmError.context.operation).toBe('generateCompletion');
expect(tmError.context.resource).toBe('mock');
}
});
it('should identify rate limit errors correctly', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
const isRateLimitError = (provider as any).isRateLimitError.bind(provider);
expect(isRateLimitError(new Error('Rate limit exceeded'))).toBe(true);
expect(isRateLimitError(new Error('Too many requests'))).toBe(true);
expect(isRateLimitError(new Error('Status: 429'))).toBe(true);
expect(isRateLimitError(new Error('Some other error'))).toBe(false);
});
it('should identify timeout errors correctly', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
const isTimeoutError = (provider as any).isTimeoutError.bind(provider);
expect(isTimeoutError(new Error('Request timeout'))).toBe(true);
expect(isTimeoutError(new Error('Operation timed out'))).toBe(true);
expect(isTimeoutError(new Error('ECONNRESET'))).toBe(true);
expect(isTimeoutError(new Error('Some other error'))).toBe(false);
});
it('should identify network errors correctly', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
const isNetworkError = (provider as any).isNetworkError.bind(provider);
expect(isNetworkError(new Error('Network error'))).toBe(true);
expect(isNetworkError(new Error('ENOTFOUND'))).toBe(true);
expect(isNetworkError(new Error('ECONNREFUSED'))).toBe(true);
expect(isNetworkError(new Error('Some other error'))).toBe(false);
});
});
describe('model management', () => {
it('should get and set model', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
expect(provider.getModel()).toBe('mock-model-v1');
provider.setModel('mock-model-v2');
expect(provider.getModel()).toBe('mock-model-v2');
});
});
describe('provider information', () => {
it('should return provider info', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
const info = provider.getProviderInfo();
expect(info.name).toBe('mock');
expect(info.displayName).toBe('Mock Provider');
expect(info.requiresApiKey).toBe(true);
expect(info.models).toHaveLength(2);
});
it('should return available models', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
const models = provider.getAvailableModels();
expect(models).toHaveLength(2);
expect(models[0].id).toBe('mock-model-v1');
expect(models[1].id).toBe('mock-model-v2');
});
it('should validate credentials', async () => {
const validProvider = new MockProvider({ apiKey: 'valid-key' });
const invalidProvider = new MockProvider({ apiKey: 'invalid-key' });
expect(await validProvider.validateCredentials()).toBe(true);
expect(await invalidProvider.validateCredentials()).toBe(false);
});
});
describe('template method pattern', () => {
it('should follow the template method flow', async () => {
const provider = new MockProvider({
apiKey: 'test-key',
responseDelay: 50
});
const startTime = Date.now();
const response = await provider.generateCompletion('Test prompt', {
temperature: 0.5,
maxTokens: 100
});
const endTime = Date.now();
// Verify the response was processed through the template
expect(response.content).toBeDefined();
expect(response.duration).toBeGreaterThanOrEqual(50);
expect(response.duration).toBeLessThanOrEqual(endTime - startTime + 10);
expect(response.timestamp).toBeDefined();
expect(response.provider).toBe('mock');
});
});
});

View File

@@ -3,26 +3,21 @@
*/
import {
generateTaskId,
isValidTaskId,
formatDate,
PlaceholderParser,
PlaceholderProvider,
PlaceholderStorage,
PlaceholderParser,
TmCoreError,
TaskNotFoundError,
ValidationError,
StorageError,
version,
name
TaskNotFoundError,
TmCoreError,
ValidationError,
formatDate,
generateTaskId,
isValidTaskId,
name,
version
} from '@/index';
import type {
TaskId,
TaskStatus,
TaskPriority,
PlaceholderTask
} from '@/types/index';
import type { PlaceholderTask, TaskId, TaskPriority, TaskStatus } from '@/types/index';
describe('tm-core smoke tests', () => {
describe('package metadata', () => {

View File

@@ -0,0 +1,46 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts', 'src/**/*.test.ts', 'src/**/*.spec.ts'],
exclude: ['node_modules', 'dist', '.git', '.cache'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules',
'dist',
'tests',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'**/index.ts'
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
setupFiles: ['./tests/setup.ts'],
testTimeout: 10000,
clearMocks: true,
restoreMocks: true,
mockReset: true
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@/types': path.resolve(__dirname, './src/types'),
'@/providers': path.resolve(__dirname, './src/providers'),
'@/storage': path.resolve(__dirname, './src/storage'),
'@/parser': path.resolve(__dirname, './src/parser'),
'@/utils': path.resolve(__dirname, './src/utils'),
'@/errors': path.resolve(__dirname, './src/errors')
}
}
});