diff --git a/.eslintignore b/.eslintignore index 40da716..5c1687d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ dist .docusaurus .coverage test/validator.test.js +__mocks__ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0171c3a..0fc9f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ All notable changes to this repository will be documented in this file. Format and process - The `Unreleased` section contains completed tasks that have been removed from `TODO.md` and are targeted for the next release. -- integration.test.task.003 — undefined — Test cleanup -- integration.test.task.002 — undefined — Integration test completion - Each entry should include: task id, summary, author, PR or commit link, and a one-line description of the change. - When creating a release, move the entries from `Unreleased` to a new versioned section (e.g. `## [1.0.0] - 2025-09-20`) and include release notes. diff --git a/__mocks__/chalk.js b/__mocks__/chalk.js new file mode 100644 index 0000000..67c378f --- /dev/null +++ b/__mocks__/chalk.js @@ -0,0 +1,43 @@ +const chalk = Object.assign((text) => text, { + green: (text) => text, + red: (text) => text, + yellow: (text) => text, + blue: (text) => text, + cyan: (text) => text, + magenta: (text) => text, + white: (text) => text, + black: (text) => text, + gray: (text) => text, + grey: (text) => text, + redBright: (text) => text, + greenBright: (text) => text, + yellowBright: (text) => text, + blueBright: (text) => text, + magentaBright: (text) => text, + cyanBright: (text) => text, + whiteBright: (text) => text, + bgRed: (text) => text, + bgGreen: (text) => text, + bgYellow: (text) => text, + bgBlue: (text) => text, + bgMagenta: (text) => text, + bgCyan: (text) => text, + bgWhite: (text) => text, + bgBlack: (text) => text, + bgRedBright: (text) => text, + bgGreenBright: (text) => text, + bgYellowBright: (text) => text, + bgBlueBright: (text) => text, + bgMagentaBright: (text) => text, + bgCyanBright: (text) => text, + bgWhiteBright: (text) => text, + bold: (text) => text, + dim: (text) => text, + italic: (text) => text, + underline: (text) => text, + inverse: (text) => text, + strikethrough: (text) => text, + reset: (text) => text, +}); + +module.exports = chalk; diff --git a/jest.config.cjs b/jest.config.cjs index 8934470..06358f1 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -35,4 +35,9 @@ module.exports = { clearMocks: true, restoreMocks: true, resetMocks: true, + transformIgnorePatterns: ['node_modules/(?!chalk)'], + moduleNameMapper: { + '^chalk$': '/__mocks__/chalk.js', + }, + forceExit: true, }; diff --git a/reference/standards/quality/code-quality.md b/reference/standards/quality/code-quality.md index e69de29..46f1417 100644 --- a/reference/standards/quality/code-quality.md +++ b/reference/standards/quality/code-quality.md @@ -0,0 +1,42 @@ +# Code Quality Standards + +## Architectural Rules + +### Index File Encapsulation + +**NEVER** allow index files to export from outside their directory tree. + +**❌ WRONG - Breaks encapsulation:** + +```typescript +// src/types/rendering/index.ts +export * from './IRenderer'; +export * from './OutputFormat'; +export { ConsoleOutputWriter } from '../../core/rendering/console-output.writer'; // ❌ BAD +``` + +**✅ CORRECT - Maintains encapsulation:** + +```typescript +// src/types/rendering/index.ts +export * from './IRenderer'; +export * from './OutputFormat'; + +// Add exports to the appropriate domain index file instead: +// src/core/rendering/index.ts +export { ConsoleOutputWriter } from './console-output.writer'; +``` + +**Rationale:** + +- Maintains clear module boundaries and encapsulation +- Prevents circular dependencies +- Makes dependencies explicit and traceable +- Follows domain-driven design principles +- Improves maintainability and refactoring safety + +**Enforcement:** + +- Code reviews must flag any index file reaching outside its directory +- Automated linting rules should be added to prevent this pattern +- Refactoring should move cross-domain exports to appropriate domain boundaries diff --git a/src/cli.ts b/src/cli.ts index ad54f20..290967a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,10 +2,11 @@ import { Command } from 'commander'; import pino from 'pino'; +import { ConsoleOutputWriter } from './core/rendering/console-output.writer'; import { getLogger } from './core/system/logger'; import { ObservabilityLogger } from './core/system/observability.logger'; -import { CommandFactory } from './commands/shared/command.factory'; -import { EXIT_CODES } from './constants/exit-codes'; +import { EXIT_CODES } from './types/core'; +import { CommandFactory } from './commands/command.factory'; /** * Main CLI entry point for the Documentation-Driven Development toolkit. @@ -36,8 +37,11 @@ const observabilityLogger = new ObservabilityLogger(pinoLogger); const program = new Command(); program.name('dddctl').description('Documentation-Driven Development CLI').version('1.0.0'); +// Create output writer for CLI messaging +const outputWriter = new ConsoleOutputWriter(); + // Configure all commands through the factory -CommandFactory.configureProgram(program, baseLogger); +CommandFactory.configureProgram(program, baseLogger, outputWriter); // Helper function to get safe command name function getCommandName(): string { diff --git a/src/commands/add-task.command.test.ts b/src/commands/add-task.command.test.ts new file mode 100644 index 0000000..8f640de --- /dev/null +++ b/src/commands/add-task.command.test.ts @@ -0,0 +1,127 @@ +import { Command } from 'commander'; + +import { TaskManager } from '../core/storage'; +import { AddTaskArgs, CommandName, EXIT_CODES, ILogger, IOutputWriter } from '../types'; + +import { AddTaskCommand } from './add-task.command'; + +// Mock dependencies +jest.mock('../core/storage'); +jest.mock('commander'); + +describe('AddTaskCommand', () => { + let logger: jest.Mocked; + let outputWriter: jest.Mocked; + let command: AddTaskCommand; + let mockTaskManager: jest.Mocked; + + beforeEach(() => { + logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } as jest.Mocked; + + outputWriter = { + info: jest.fn(), + error: jest.fn(), + success: jest.fn(), + warning: jest.fn(), + write: jest.fn(), + newline: jest.fn(), + writeFormatted: jest.fn(), + section: jest.fn(), + keyValue: jest.fn(), + } as jest.Mocked; + + mockTaskManager = { + addTaskFromFile: jest.fn(), + } as unknown as jest.Mocked; + + // Mock the TaskManager constructor + (TaskManager as jest.MockedClass).mockImplementation(() => mockTaskManager); + + command = new AddTaskCommand(logger, outputWriter); + jest.clearAllMocks(); + }); + + describe('execute', () => { + const args: AddTaskArgs = { file: 'test-task.md' }; + + it('should handle successful task addition', async () => { + mockTaskManager.addTaskFromFile.mockReturnValue(true); + + await command.execute(args); + + expect(mockTaskManager.addTaskFromFile).toHaveBeenCalledWith(args.file); + expect(outputWriter.success).toHaveBeenCalledWith(`Task added to TODO.md from ${args.file}`); + expect(logger.info).toHaveBeenCalledWith('Task added successfully', { file: args.file }); + expect(process.exitCode).toBeUndefined(); + }); + + it('should handle failed task addition', async () => { + mockTaskManager.addTaskFromFile.mockReturnValue(false); + + await command.execute(args); + + expect(mockTaskManager.addTaskFromFile).toHaveBeenCalledWith(args.file); + expect(logger.error).toHaveBeenCalledWith(`Failed to add task from ${args.file}`, { + file: args.file, + }); + expect(process.exitCode).toBe(EXIT_CODES.GENERAL_ERROR); + }); + + it('should handle errors during task addition', async () => { + const error = new Error('File not found'); + const mockImplementation = () => { + throw error; + }; + mockTaskManager.addTaskFromFile.mockImplementation(mockImplementation); + + await command.execute(args); + + expect(mockTaskManager.addTaskFromFile).toHaveBeenCalledWith(args.file); + expect(logger.error).toHaveBeenCalledWith(`Error adding task: ${error.message}`, { + error: error.message, + file: args.file, + }); + expect(process.exitCode).toBe(EXIT_CODES.NOT_FOUND); + }); + + it('should handle unknown errors during task addition', async () => { + const error = 'Unknown error'; + const mockImplementation = () => { + throw error; + }; + mockTaskManager.addTaskFromFile.mockImplementation(mockImplementation); + + await command.execute(args); + + expect(mockTaskManager.addTaskFromFile).toHaveBeenCalledWith(args.file); + expect(logger.error).toHaveBeenCalledWith(`Error adding task: ${error}`, { + error: error, + file: args.file, + }); + expect(process.exitCode).toBe(EXIT_CODES.NOT_FOUND); + }); + }); + + describe('configure', () => { + it('should configure the command with Commander.js', () => { + const parent = { + command: jest.fn().mockReturnValue({ + argument: jest.fn().mockReturnThis(), + description: jest.fn().mockReturnThis(), + action: jest.fn().mockReturnThis(), + }), + } as unknown as Command; + + AddTaskCommand.configure(parent, logger, outputWriter); + + expect(parent.command).toHaveBeenCalledWith(CommandName.ADD); + // Additional assertions can be added if needed for argument and action setup + }); + }); +}); diff --git a/src/commands/task-management/add-task.command.ts b/src/commands/add-task.command.ts similarity index 53% rename from src/commands/task-management/add-task.command.ts rename to src/commands/add-task.command.ts index 2d557d8..4209e5c 100644 --- a/src/commands/task-management/add-task.command.ts +++ b/src/commands/add-task.command.ts @@ -1,12 +1,10 @@ -import chalk from 'chalk'; import { Command } from 'commander'; -import { ILogger } from '../../types/observability'; -import { TaskManager } from '../../core/storage/task.manager'; -import { AddTaskArgs } from '../../types/tasks'; -import { EXIT_CODES } from '../../constants/exit-codes'; -import { BaseCommand } from '../shared/base.command'; -import { CommandName } from '../../types'; +import { ConsoleOutputWriter } from '../core/rendering'; +import { TaskManager } from '../core/storage'; +import { AddTaskArgs, CommandName, EXIT_CODES, ILogger, IOutputWriter } from '../types'; + +import { BaseCommand } from './base.command'; /** * Command for adding a new task from a file to the TODO.md. @@ -26,6 +24,13 @@ export class AddTaskCommand extends BaseCommand { readonly name = CommandName.ADD; readonly description = 'Add a new task from a file'; + constructor( + logger: ILogger, + protected override readonly outputWriter: IOutputWriter = new ConsoleOutputWriter(), + ) { + super(logger, outputWriter); + } + /** * Executes the add task command. * @@ -45,19 +50,47 @@ export class AddTaskCommand extends BaseCommand { try { const manager = new TaskManager(this.logger); const added = manager.addTaskFromFile(args.file); + if (added) { - console.log(chalk.green(`Task added to TODO.md from ${args.file}`)); - this.logger.info('Task added successfully', { file: args.file }); - } else { - this.logger.error(`Failed to add task from ${args.file}`, { file: args.file }); - process.exitCode = EXIT_CODES.GENERAL_ERROR; + this.handleSuccessfulAddition(args.file); + return Promise.resolve(); } + + this.handleFailedAddition(args.file); + return Promise.resolve(); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Error adding task: ${message}`, { error: message, file: args.file }); - process.exitCode = EXIT_CODES.NOT_FOUND; + this.handleAdditionError(error, args.file); + return Promise.resolve(); } - return Promise.resolve(); + } + + /** + * Handles successful task addition. + * @param filePath - The path of the file being processed + */ + private handleSuccessfulAddition(filePath: string): void { + this.outputWriter.success(`Task added to TODO.md from ${filePath}`); + this.logger.info('Task added successfully', { file: filePath }); + } + + /** + * Handles failed task addition. + * @param filePath - The path of the file being processed + */ + private handleFailedAddition(filePath: string): void { + this.logger.error(`Failed to add task from ${filePath}`, { file: filePath }); + process.exitCode = EXIT_CODES.GENERAL_ERROR; + } + + /** + * Handles errors during task addition. + * @param error - The error that occurred + * @param filePath - The path of the file being processed + */ + private handleAdditionError(error: unknown, filePath: string): void { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Error adding task: ${message}`, { error: message, file: filePath }); + process.exitCode = EXIT_CODES.NOT_FOUND; } /** @@ -75,13 +108,13 @@ export class AddTaskCommand extends BaseCommand { * AddTaskCommand.configure(program); * ``` */ - static configure(parent: Command, logger: ILogger): void { + static configure(parent: Command, logger: ILogger, outputWriter?: IOutputWriter): void { parent .command(CommandName.ADD) .argument('', 'File containing the task to add') .description('Add a new task from a file') .action(async (file: string) => { - const cmd = new AddTaskCommand(logger); + const cmd = new AddTaskCommand(logger, outputWriter); await cmd.execute({ file }); }); } diff --git a/src/commands/audit/ref-audit.command.test.ts b/src/commands/audit/ref-audit.command.test.ts deleted file mode 100644 index 2a203f0..0000000 --- a/src/commands/audit/ref-audit.command.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* eslint-disable no-undefined */ -import { Command } from 'commander'; - -import { container } from '../../core/system/container'; -import { SERVICE_KEYS } from '../../types/core'; -import { ILogger } from '../../types/observability'; -import { CommandName, IReferenceAuditUseCase } from '../../types'; - -import { RefAuditCommand } from './ref-audit.command'; - -jest.mock('../../core/system/container'); -jest.mock('commander'); - -describe('RefAuditCommand', () => { - let mockLogger: jest.Mocked; - let mockService: jest.Mocked; - let mockParentCommand: jest.Mocked; - - beforeEach(() => { - mockLogger = { - info: jest.fn(), - } as unknown as jest.Mocked; - - mockService = { - execute: jest.fn().mockResolvedValue(undefined), - } as jest.Mocked; - - mockParentCommand = { - command: jest.fn().mockReturnThis(), - description: jest.fn().mockReturnThis(), - action: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - (container.resolve as jest.Mock).mockReturnValue(mockService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should have correct name and description', () => { - const cmd = new RefAuditCommand(mockLogger); - expect(cmd.name).toBe(CommandName.AUDIT); - expect(cmd.description).toBe('Audit references across repo & tasks'); - }); - - it('should execute the command successfully', async () => { - const cmd = new RefAuditCommand(mockLogger); - await cmd.execute(); - - expect(mockLogger.info).toHaveBeenCalledWith('Executing ref audit command'); - expect(container.resolve).toHaveBeenCalledWith(SERVICE_KEYS.REFERENCE_AUDIT); - expect(mockService.execute).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith('Ref audit command executed'); - }); - - it('should configure the command on parent', () => { - RefAuditCommand.configure(mockParentCommand, mockLogger); - - expect(mockParentCommand.command).toHaveBeenCalledWith(CommandName.AUDIT); - expect(mockParentCommand.description).toHaveBeenCalledWith( - 'Audit references across repo & tasks', - ); - expect(mockParentCommand.action).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('should handle service execution error', async () => { - const error = new Error('Service error'); - mockService.execute.mockRejectedValue(error); - - const cmd = new RefAuditCommand(mockLogger); - - await expect(cmd.execute()).rejects.toThrow('Service error'); - expect(mockLogger.info).toHaveBeenCalledWith('Executing ref audit command'); - expect(mockLogger.info).not.toHaveBeenCalledWith('Ref audit command executed'); - }); -}); diff --git a/src/commands/base.command.test.ts b/src/commands/base.command.test.ts new file mode 100644 index 0000000..ddb97ce --- /dev/null +++ b/src/commands/base.command.test.ts @@ -0,0 +1,132 @@ +import { ILogger, IOutputWriter, CommandName } from '../types'; + +import { BaseCommand } from './base.command'; + +describe('BaseCommand', () => { + class TestCommand extends BaseCommand { + name = CommandName.ADD; + description = 'test command'; + + async execute(_args?: unknown): Promise { + // Implementation for testing + } + } + + let mockLogger: jest.Mocked; + let mockOutputWriter: jest.Mocked; + let testCommand: TestCommand; + + beforeEach(() => { + mockLogger = { + info: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + mockOutputWriter = { + info: jest.fn(), + error: jest.fn(), + success: jest.fn(), + warning: jest.fn(), + write: jest.fn(), + newline: jest.fn(), + writeFormatted: jest.fn(), + section: jest.fn(), + keyValue: jest.fn(), + } as unknown as jest.Mocked; + + testCommand = new TestCommand(mockLogger, mockOutputWriter); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with a logger and output writer', () => { + expect(testCommand).toBeInstanceOf(BaseCommand); + }); + + it('should use default ConsoleOutputWriter when no output writer provided', () => { + const commandWithDefaultWriter = new TestCommand(mockLogger); + expect(commandWithDefaultWriter).toBeInstanceOf(BaseCommand); + }); + }); + + describe('logInfo', () => { + it.each([ + { message: 'Simple info message', description: 'simple message' }, + { message: 'Complex info message with details', description: 'complex message' }, + { message: '', description: 'empty message' }, + { message: 'Message with\nnewlines', description: 'message with newlines' }, + ])('should call outputWriter.info and logger.info for $description', ({ message }) => { + testCommand['logInfo'](message); + + expect(mockOutputWriter.info).toHaveBeenCalledWith(message); + expect(mockOutputWriter.info).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith(message); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple calls correctly', () => { + const messages = ['First message', 'Second message', 'Third message']; + + messages.forEach((message) => { + testCommand['logInfo'](message); + }); + + expect(mockOutputWriter.info).toHaveBeenCalledTimes(3); + expect(mockLogger.info).toHaveBeenCalledTimes(3); + messages.forEach((message, index) => { + expect(mockOutputWriter.info).toHaveBeenNthCalledWith(index + 1, message); + expect(mockLogger.info).toHaveBeenNthCalledWith(index + 1, message); + }); + }); + }); + + describe('logError', () => { + it.each([ + { message: 'Simple error message', description: 'simple message' }, + { message: 'Complex error message with details', description: 'complex message' }, + { message: '', description: 'empty message' }, + { message: 'Error with\nstack trace', description: 'message with newlines' }, + ])('should call outputWriter.error and logger.error for $description', ({ message }) => { + testCommand['logError'](message); + + expect(mockOutputWriter.error).toHaveBeenCalledWith(message); + expect(mockOutputWriter.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith(message); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple calls correctly', () => { + const messages = ['First error', 'Second error', 'Third error']; + + messages.forEach((message) => { + testCommand['logError'](message); + }); + + expect(mockOutputWriter.error).toHaveBeenCalledTimes(3); + expect(mockLogger.error).toHaveBeenCalledTimes(3); + messages.forEach((message, index) => { + expect(mockOutputWriter.error).toHaveBeenNthCalledWith(index + 1, message); + expect(mockLogger.error).toHaveBeenNthCalledWith(index + 1, message); + }); + }); + }); + + describe('integration with output writer methods', () => { + it('should properly integrate with output writer for success messages', () => { + const message = 'Task completed successfully'; + testCommand['logInfo'](message); + + expect(mockOutputWriter.info).toHaveBeenCalledWith(message); + }); + + it('should properly integrate with output writer for error messages', () => { + const message = 'Operation failed'; + testCommand['logError'](message); + + expect(mockOutputWriter.error).toHaveBeenCalledWith(message); + }); + }); +}); diff --git a/src/commands/base.command.ts b/src/commands/base.command.ts new file mode 100644 index 0000000..6a3c14f --- /dev/null +++ b/src/commands/base.command.ts @@ -0,0 +1,48 @@ +import { ConsoleOutputWriter } from '../core/rendering'; +import { ICommand, ILogger, IOutputWriter } from '../types'; +import { CommandName } from '../types/commands'; + +export abstract class BaseCommand implements ICommand { + abstract name: CommandName; + abstract description: string; + + constructor( + protected readonly logger: ILogger, + protected readonly outputWriter: IOutputWriter = new ConsoleOutputWriter(), + ) {} + + /** + * Logs an informational message to both the output writer and the logger. + * @param message - The message to log. + */ + protected logInfo(message: string): void { + this.outputWriter.info(message); + this.logger.info(message); + } + + /** + * Logs a warning message to both the output writer and the logger. + * @param message - The message to log. + */ + protected logWarning(message: string): void { + this.outputWriter.warning(message); + this.logger.warn(message); + } + + /** + * Logs an error message to both the output writer and the logger. + * @param message - The message to log. + */ + protected logError(message: string): void { + this.outputWriter.error(message); + this.logger.error(message); + } + + /** + * Abstract method to execute the command with optional arguments. + * Must be implemented by subclasses. + * @param args - Optional arguments for command execution. + * @returns A promise that resolves when the command execution is complete. + */ + abstract execute(args?: unknown): Promise; +} diff --git a/src/commands/command.factory.test.ts b/src/commands/command.factory.test.ts new file mode 100644 index 0000000..6c20522 --- /dev/null +++ b/src/commands/command.factory.test.ts @@ -0,0 +1,177 @@ +import { Command } from 'commander'; + +import { ILogger, IOutputWriter } from '../types'; + +import { AddTaskCommand } from './add-task.command'; +import { CommandFactory } from './command.factory'; +import { CompleteTaskCommand } from './complete-task.command'; +import { ListTasksCommand } from './list-tasks.command'; +import { NextCommand } from './next.command'; +import { RefAuditCommand } from './ref-audit.command'; +import { RenderCommand } from './render.command'; +import { ShowTaskCommand } from './show-task.command'; +import { SupersedeCommand } from './supersede.command'; +import { ValidateAndFixCommand } from './validate-and-fix.command'; +import { ValidateTasksCommand } from './validate-tasks.command'; + +// Mock chalk to handle ES module import issues +jest.mock('chalk', () => ({ + default: { + green: jest.fn((text) => text), + yellow: jest.fn((text) => text), + red: jest.fn((text) => text), + blue: jest.fn((text) => text), + bold: jest.fn((text) => text), + dim: jest.fn((text) => text), + }, + green: jest.fn((text) => text), + yellow: jest.fn((text) => text), + red: jest.fn((text) => text), + blue: jest.fn((text) => text), + bold: jest.fn((text) => text), + dim: jest.fn((text) => text), +})); + +// Mock all command modules +jest.mock('./add-task.command'); +jest.mock('./complete-task.command'); +jest.mock('./list-tasks.command'); +jest.mock('./show-task.command'); +jest.mock('./validate-and-fix.command'); +jest.mock('./validate-tasks.command'); +jest.mock('./next.command'); +jest.mock('./render.command'); +jest.mock('./ref-audit.command'); +jest.mock('./supersede.command'); + +// Import mocked command classes + +describe('CommandFactory', () => { + let mockLogger: ILogger; + let mockOutputWriter: IOutputWriter; + let mockProgram: jest.Mocked; + let mockRefCommand: jest.Mocked; + let mockTaskCommand: jest.Mocked; + let mockValidateCommand: jest.Mocked; + + beforeEach(() => { + mockLogger = {} as ILogger; // Mock logger, assuming it's just passed through + mockOutputWriter = {} as IOutputWriter; // Mock output writer + + mockRefCommand = { + description: jest.fn().mockReturnThis(), + } as unknown as jest.Mocked; + + mockTaskCommand = { + description: jest.fn().mockReturnThis(), + } as unknown as jest.Mocked; + + mockValidateCommand = { + description: jest.fn().mockReturnThis(), + } as unknown as jest.Mocked; + + mockProgram = { + command: jest.fn(), + } as unknown as jest.Mocked; + + // Mock the command method to return appropriate subcommands + mockProgram.command.mockImplementation((name: string) => { + if (name === 'ref') return mockRefCommand; + if (name === 'task') return mockTaskCommand; + if (name === 'validate') return mockValidateCommand; + return mockProgram; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('configureProgram', () => { + it('should configure all commands correctly', () => { + CommandFactory.configureProgram(mockProgram, mockLogger, mockOutputWriter); + + // Verify core commands are configured + expect(NextCommand.configure).toHaveBeenCalledWith(mockProgram, mockLogger); + expect(RenderCommand.configure).toHaveBeenCalledWith(mockProgram, mockLogger); + expect(SupersedeCommand.configure).toHaveBeenCalledWith(mockProgram, mockLogger); + + // Verify ref subcommand is created and configured + expect(mockProgram.command).toHaveBeenCalledWith('ref'); + expect(mockRefCommand.description).toHaveBeenCalledWith('Reference management'); + expect(RefAuditCommand.configure).toHaveBeenCalledWith(mockRefCommand, mockLogger); + + // Verify task subcommand is created and configured + expect(mockProgram.command).toHaveBeenCalledWith('task'); + expect(mockTaskCommand.description).toHaveBeenCalledWith('Task management commands'); + expect(AddTaskCommand.configure).toHaveBeenCalledWith( + mockTaskCommand, + mockLogger, + mockOutputWriter, + ); + expect(CompleteTaskCommand.configure).toHaveBeenCalledWith( + mockTaskCommand, + mockLogger, + mockOutputWriter, + ); + expect(ListTasksCommand.configure).toHaveBeenCalledWith( + mockTaskCommand, + mockLogger, + mockOutputWriter, + ); + expect(ShowTaskCommand.configure).toHaveBeenCalledWith( + mockTaskCommand, + mockLogger, + mockOutputWriter, + ); + + // Verify validate subcommand is created and configured + expect(mockProgram.command).toHaveBeenCalledWith('validate'); + expect(mockValidateCommand.description).toHaveBeenCalledWith('Validation commands'); + expect(ValidateTasksCommand.configure).toHaveBeenCalledWith(mockValidateCommand, mockLogger); + expect(ValidateAndFixCommand.configure).toHaveBeenCalledWith( + mockValidateCommand, + mockLogger, + mockOutputWriter, + ); + }); + }); + + it('should handle missing output writer gracefully', () => { + expect(() => { + CommandFactory.configureProgram(mockProgram, mockLogger, null as any); + }).not.toThrow(); + }); + + it('should handle missing logger gracefully', () => { + expect(() => { + CommandFactory.configureProgram(mockProgram, null as any, mockOutputWriter); + }).not.toThrow(); + }); + + it('should configure task commands with output writer', () => { + CommandFactory.configureProgram(mockProgram, mockLogger, mockOutputWriter); + + expect(AddTaskCommand.configure).toHaveBeenCalledWith( + mockTaskCommand, + mockLogger, + mockOutputWriter, + ); + expect(CompleteTaskCommand.configure).toHaveBeenCalledWith( + mockTaskCommand, + mockLogger, + mockOutputWriter, + ); + }); + + it('should configure validation commands appropriately', () => { + CommandFactory.configureProgram(mockProgram, mockLogger, mockOutputWriter); + + expect(ValidateTasksCommand.configure).toHaveBeenCalledWith(mockValidateCommand, mockLogger); + expect(ValidateAndFixCommand.configure).toHaveBeenCalledWith( + mockValidateCommand, + mockLogger, + mockOutputWriter, + ); + }); +}); diff --git a/src/commands/command.factory.ts b/src/commands/command.factory.ts new file mode 100644 index 0000000..3539104 --- /dev/null +++ b/src/commands/command.factory.ts @@ -0,0 +1,49 @@ +import { Command } from 'commander'; + +import { ILogger, IOutputWriter } from '../types'; + +import { AddTaskCommand } from './add-task.command'; +import { CompleteTaskCommand } from './complete-task.command'; +import { ListTasksCommand } from './list-tasks.command'; +import { NextCommand } from './next.command'; +import { RefAuditCommand } from './ref-audit.command'; +import { RenderCommand } from './render.command'; +import { ShowTaskCommand } from './show-task.command'; +import { SupersedeCommand } from './supersede.command'; +import { ValidateAndFixCommand } from './validate-and-fix.command'; +import { ValidateTasksCommand } from './validate-tasks.command'; + +/** + * Factory for creating and configuring CLI commands following Command pattern. + * Centralizes command registration and configuration. + */ +export class CommandFactory { + /** + * Configures all commands on the given Commander program instance. + * @param program The Commander program instance to configure commands on. + * @param logger Logger instance for command logging. + * @param outputWriter Optional output writer for command output. + */ + static configureProgram(program: Command, logger: ILogger, outputWriter?: IOutputWriter): void { + // Core commands + NextCommand.configure(program, logger); + RenderCommand.configure(program, logger); + SupersedeCommand.configure(program, logger); + + // Reference commands + const ref = program.command('ref').description('Reference management'); + RefAuditCommand.configure(ref, logger); + + // Task commands + const task = program.command('task').description('Task management commands'); + AddTaskCommand.configure(task, logger, outputWriter); + CompleteTaskCommand.configure(task, logger, outputWriter); + ListTasksCommand.configure(task, logger, outputWriter); + ShowTaskCommand.configure(task, logger, outputWriter); + + // Validate commands + const validate = program.command('validate').description('Validation commands'); + ValidateTasksCommand.configure(validate, logger); + ValidateAndFixCommand.configure(validate, logger, outputWriter); + } +} diff --git a/src/commands/task-management/complete-task.command.ts b/src/commands/complete-task.command.ts similarity index 57% rename from src/commands/task-management/complete-task.command.ts rename to src/commands/complete-task.command.ts index 7e93509..30c922e 100644 --- a/src/commands/task-management/complete-task.command.ts +++ b/src/commands/complete-task.command.ts @@ -1,11 +1,16 @@ -import chalk from 'chalk'; import { Command } from 'commander'; -import { CompleteTaskArgs, CompleteTaskOptions } from '../../types/tasks'; -import { ILogger } from '../../types/observability'; -import { EXIT_CODES } from '../../constants/exit-codes'; -import { TaskManager } from '../../core/storage/task.manager'; -import { CommandName, ICommand } from '../../types'; +import { ConsoleOutputWriter } from '../core/rendering'; +import { TaskManager } from '../core/storage'; +import { + CommandName, + CompleteTaskArgs, + CompleteTaskOptions, + EXIT_CODES, + ICommand, + ILogger, + IOutputWriter, +} from '../types'; /** * Command for completing a task by removing it @@ -15,10 +20,21 @@ export class CompleteTaskCommand implements ICommand { readonly name = CommandName.COMPLETE; readonly description = 'Mark a task as completed'; - constructor(private readonly logger: ILogger) {} + constructor( + private readonly logger: ILogger, + private readonly outputWriter: IOutputWriter = new ConsoleOutputWriter(), + ) {} /** * Removes the task from TODO.md and adds an entry to CHANGELOG.md. + * @param args - Arguments containing the task ID to complete + * @param options - Options for completion, including message and dry run flag + * @returns Promise that resolves when the operation is complete + * + * @example + * ```typescript + * await command.execute({ id: 'TASK-123' }, { message: 'Fixed the issue', dryRun: false }); + * ``` */ execute(args: CompleteTaskArgs, options: CompleteTaskOptions = {}): Promise { const todoManager = new TaskManager(this.logger); @@ -31,14 +47,14 @@ export class CompleteTaskCommand implements ICommand { return Promise.reject(message); } - const summary = options.message ?? task['summary']; - const changelogEntry = `${task.id} — ${task['summary']} — ${summary}`; + const summary = options.message ?? task.title ?? ''; + const changelogEntry = `${task.id} — ${task.title ?? ''} — ${summary}`; // Handle dry run if (options.dryRun === true) { const preview = todoManager.previewComplete(args.id); - console.log(chalk.yellow('Dry run preview:')); - console.log(preview); + this.outputWriter.warning('Dry run preview:'); + this.outputWriter.write(preview); this.logger.info('Dry run preview generated', { id: args.id }); return Promise.resolve(); } @@ -50,6 +66,9 @@ export class CompleteTaskCommand implements ICommand { /** * Performs the actual task completion by removing from TODO and adding to changelog. + * @param id - The ID of the task to complete + * @param changelogEntry - The entry to add to the changelog + * @param todoManager - The TaskManager instance to use for operations */ private performTaskCompletion( id: string, @@ -70,7 +89,13 @@ export class CompleteTaskCommand implements ICommand { }); } - static configure(parent: Command, logger: ILogger): void { + /** + * Configures the complete task command in the CLI program. + * @param parent The parent Commander command to attach this command to. + * @param logger Logger instance for command logging. + * @param outputWriter Optional output writer for command output. + */ + static configure(parent: Command, logger: ILogger, outputWriter?: IOutputWriter): void { parent .command(CommandName.COMPLETE) .argument('', 'Task ID to complete') @@ -78,7 +103,7 @@ export class CompleteTaskCommand implements ICommand { .option('--dry-run', 'Perform dry run without making changes') .description('Mark a task as completed') .action((id: string, options: CompleteTaskOptions) => { - const cmd = new CompleteTaskCommand(logger); + const cmd = new CompleteTaskCommand(logger, outputWriter); return cmd.execute({ id }, options); }); } diff --git a/src/commands/list-tasks.command.ts b/src/commands/list-tasks.command.ts new file mode 100644 index 0000000..34e62e2 --- /dev/null +++ b/src/commands/list-tasks.command.ts @@ -0,0 +1,60 @@ +import { Command } from 'commander'; + +import { TaskManager } from '../core/storage'; +import { CommandName, ILogger, IOutputWriter } from '../types'; + +import { BaseCommand } from './base.command'; + +/** + * Modern command for listing all tasks from the TODO.md file. + */ +export class ListTasksCommand extends BaseCommand { + readonly name = CommandName.LIST; + readonly description = 'List all tasks'; + + constructor(logger: ILogger, outputWriter?: IOutputWriter) { + super(logger, outputWriter); + } + + /** + * Executes the list tasks command that retrieves all tasks from + * TODO.md and displays them in a formatted list. + * @returns Promise that resolves when the operation is complete + * + * @example + * ```typescript + * await command.execute(); + * ``` + */ + execute(): Promise { + const todoManager = new TaskManager(this.logger); + const tasks = todoManager.listTasks(); + + if (!tasks.length) { + this.outputWriter.warning('No tasks found in TODO.md'); + return Promise.resolve(); + } + + for (const task of tasks) { + this.outputWriter.write(`${task.id} ${task['priority'] ?? 'P2'} ${task['summary'] ?? ''}`); + } + + return Promise.resolve(); + } + + /** + * Configures the list tasks command in the CLI program. + * @param parent The parent Commander command to attach this command to. + * @param logger Logger instance for command logging. + * @param outputWriter Optional output writer for command output. + */ + static configure(parent: Command, logger: ILogger, outputWriter?: IOutputWriter): void { + parent + .command(CommandName.LIST) + .description('List all tasks') + .action(async () => { + const cmd = new ListTasksCommand(logger, outputWriter); + await cmd.execute(); + }); + } +} diff --git a/src/commands/rendering/next.command.telemetry.test.ts b/src/commands/next.command.telemetry.test.ts similarity index 94% rename from src/commands/rendering/next.command.telemetry.test.ts rename to src/commands/next.command.telemetry.test.ts index 0e1e67b..1dc7b28 100644 --- a/src/commands/rendering/next.command.telemetry.test.ts +++ b/src/commands/next.command.telemetry.test.ts @@ -1,9 +1,13 @@ /* eslint-disable no-undefined */ -import { CommandName } from '../../types'; -import { IObservabilityLogger } from '../../types/observability'; -import { IHydrationOptions, TaskProviderType } from '../../types/tasks'; +import { + CommandName, + IHydrationOptions, + IObservabilityLogger, + IOperationContext, + TaskProviderType, +} from '../types'; -import { NextCommandTelemetry, OperationContext } from './next.command.telemetry'; +import { NextCommandTelemetry } from './next.command.telemetry'; describe('NextCommandTelemetry', () => { let telemetry: NextCommandTelemetry; @@ -84,7 +88,7 @@ describe('NextCommandTelemetry', () => { describe('noTaskFound', () => { it('should log warning, counter, and event for no tasks found', () => { - const op: OperationContext = { + const op: IOperationContext = { operationLogger: mockOperationLogger, startTime: new Date(), stopTimer: jest.fn(), @@ -111,7 +115,7 @@ describe('NextCommandTelemetry', () => { }); it('should handle undefined provider', () => { - const op: OperationContext = { + const op: IOperationContext = { operationLogger: mockOperationLogger, startTime: new Date(), stopTimer: jest.fn(), @@ -136,7 +140,7 @@ describe('NextCommandTelemetry', () => { describe('success', () => { it('should log success span, info, counter, and event', () => { const startTime = new Date(); - const op: OperationContext = { + const op: IOperationContext = { operationLogger: mockOperationLogger, startTime, stopTimer: jest.fn(), @@ -176,7 +180,7 @@ describe('NextCommandTelemetry', () => { }); it('should handle undefined provider', () => { - const op: OperationContext = { + const op: IOperationContext = { operationLogger: mockOperationLogger, startTime: new Date(), stopTimer: jest.fn(), @@ -203,7 +207,7 @@ describe('NextCommandTelemetry', () => { describe('error', () => { it('should log error span, error, counter, and event for Error instance', () => { const startTime = new Date(); - const op: OperationContext = { + const op: IOperationContext = { operationLogger: mockOperationLogger, startTime, stopTimer: jest.fn(), @@ -242,7 +246,7 @@ describe('NextCommandTelemetry', () => { }); it('should handle non-Error err', () => { - const op: OperationContext = { + const op: IOperationContext = { operationLogger: mockOperationLogger, startTime: new Date(), stopTimer: jest.fn(), diff --git a/src/commands/rendering/next.command.telemetry.ts b/src/commands/next.command.telemetry.ts similarity index 71% rename from src/commands/rendering/next.command.telemetry.ts rename to src/commands/next.command.telemetry.ts index f21eef1..13642ff 100644 --- a/src/commands/rendering/next.command.telemetry.ts +++ b/src/commands/next.command.telemetry.ts @@ -1,15 +1,19 @@ -import { CommandName } from '../../types'; -import { IObservabilityLogger } from '../../types/observability'; -import { IHydrationOptions, TaskProviderType } from '../../types/tasks'; - -export interface OperationContext { - operationLogger: IObservabilityLogger; - startTime: Date; - stopTimer: () => void; -} +import { + CommandName, + IHydrationOptions, + IObservabilityLogger, + IOperationContext, + TaskProviderType, +} from '../types'; export class NextCommandTelemetry { - recordStart(obs: IObservabilityLogger, options: IHydrationOptions): OperationContext { + /** + * Records the start of the next command execution. + * @param obs - The observability logger instance + * @param options - The hydration options used + * @returns OperationContext containing logger, start time, and timer stop function + */ + recordStart(obs: IObservabilityLogger, options: IHydrationOptions): IOperationContext { const correlationId = obs.createCorrelationId(); const operationLogger = obs.withCorrelation(correlationId, 'next_command_execution', { provider: options.provider, @@ -36,7 +40,12 @@ export class NextCommandTelemetry { return { operationLogger, startTime, stopTimer }; } - noTaskFound(op: OperationContext, options: IHydrationOptions): void { + /** + * Logs when no eligible tasks are found for hydration. + * @param op - The operation context + * @param options - The hydration options used + */ + noTaskFound(op: IOperationContext, options: IHydrationOptions): void { op.operationLogger.warn('No eligible tasks found for next command', { provider: options.provider ?? TaskProviderType.TASK, filters: options.filters, @@ -51,7 +60,13 @@ export class NextCommandTelemetry { }); } - success(op: OperationContext, taskId: string, provider: string | undefined): void { + /** + * Records successful command execution with metrics and events. + * @param op - The operation context + * @param taskId - The ID of the hydrated task + * @param provider - The task provider type + */ + success(op: IOperationContext, taskId: string, provider: string | undefined): void { const endTime = new Date(); const duration = endTime.getTime() - op.startTime.getTime(); op.stopTimer(); @@ -78,7 +93,13 @@ export class NextCommandTelemetry { }); } - error(op: OperationContext, err: unknown, provider: string | undefined): void { + /** + * Handles errors during command execution. + * @param op - The operation context + * @param err - The error that occurred + * @param provider - The task provider type + */ + error(op: IOperationContext, err: unknown, provider: string | undefined): void { const endTime = new Date(); const duration = endTime.getTime() - op.startTime.getTime(); op.stopTimer(); diff --git a/src/commands/rendering/next.command.test.ts b/src/commands/next.command.test.ts similarity index 86% rename from src/commands/rendering/next.command.test.ts rename to src/commands/next.command.test.ts index 932bcf0..c5cc620 100644 --- a/src/commands/rendering/next.command.test.ts +++ b/src/commands/next.command.test.ts @@ -1,20 +1,26 @@ /* eslint-disable no-undefined */ import { Command } from 'commander'; -import { ILogger, IObservabilityLogger } from '../../types/observability'; -import { ITaskRepository } from '../../types/repository'; -import { IHydrationOptions, ITask, TaskState } from '../../types/tasks'; -import { TaskProviderType } from '../../types'; -import { TaskProviderFactory } from '../../core/storage/task-provider.factory'; -import { TaskHydrationService } from '../../core/processing/hydrate'; -import { ObservabilityLoggerAdapter } from '../../core/system/observability-logger.adapter'; +import { TaskHydrationService } from '../core/processing/hydrate'; +import { TaskProviderFactory } from '../core/storage'; +import { ObservabilityLoggerAdapter } from '../core/system/observability-logger.adapter'; +import { + IHydrationOptions, + ILogger, + IObservabilityLogger, + ITask, + ITaskRepository, + IOperationContext, + TaskProviderType, + TaskState, +} from '../types'; import { NextCommand } from './next.command'; -import { NextCommandTelemetry, OperationContext } from './next.command.telemetry'; +import { NextCommandTelemetry } from './next.command.telemetry'; -jest.mock('../../core/storage/task-provider.factory'); -jest.mock('../../core/processing/hydrate'); -jest.mock('../../core/system/observability-logger.adapter'); +jest.mock('../core/storage/task-provider.factory'); +jest.mock('../core/processing/hydrate'); +jest.mock('../core/system/observability-logger.adapter'); jest.mock('./next.command.telemetry'); describe('NextCommand', () => { @@ -93,7 +99,7 @@ describe('NextCommand', () => { describe('execute', () => { it('should handle no task found', async () => { provider.findNextEligible.mockResolvedValue(null); - telemetry.recordStart.mockReturnValue({} as OperationContext); + telemetry.recordStart.mockReturnValue({} as IOperationContext); const command = new NextCommand(logger, observabilityLogger); await command.execute(options); @@ -104,7 +110,7 @@ describe('NextCommand', () => { it('should hydrate and update task when found', async () => { provider.findNextEligible.mockResolvedValue(task); - telemetry.recordStart.mockReturnValue({} as OperationContext); + telemetry.recordStart.mockReturnValue({} as IOperationContext); hydrationService.hydrateTask.mockResolvedValue(task); const command = new NextCommand(logger, observabilityLogger); @@ -123,7 +129,7 @@ describe('NextCommand', () => { it('should set dddKitCommit if pin is provided', async () => { options.pin = 'abc123'; provider.findNextEligible.mockResolvedValue(task); - telemetry.recordStart.mockReturnValue({} as OperationContext); + telemetry.recordStart.mockReturnValue({} as IOperationContext); hydrationService.hydrateTask.mockResolvedValue(task); const command = new NextCommand(logger, observabilityLogger); @@ -136,7 +142,7 @@ describe('NextCommand', () => { it('should throw error on failure', async () => { provider.findNextEligible.mockRejectedValue(new Error('Test error')); - telemetry.recordStart.mockReturnValue({} as OperationContext); + telemetry.recordStart.mockReturnValue({} as IOperationContext); const command = new NextCommand(logger, observabilityLogger); await expect(command.execute(options)).rejects.toThrow('Test error'); diff --git a/src/commands/rendering/next.command.ts b/src/commands/next.command.ts similarity index 63% rename from src/commands/rendering/next.command.ts rename to src/commands/next.command.ts index 62a8464..28031e6 100644 --- a/src/commands/rendering/next.command.ts +++ b/src/commands/next.command.ts @@ -1,19 +1,27 @@ import { Command } from 'commander'; -import { CommandName, TaskProviderType } from '../../types'; -import { ILogger, IObservabilityLogger } from '../../types/observability'; -import { NextCommandOptions } from '../../types/rendering'; -import { ITaskRepository } from '../../types/repository'; -import { IHydrationOptions, ITask, TaskState } from '../../types/tasks'; -import { TaskProviderFactory } from '../../core/storage/task-provider.factory'; -import { isNullOrUndefined } from '../../core/helpers/type-guards'; -import { TaskHydrationService } from '../../core/processing/hydrate'; -import { Resolver } from '../../core/helpers/uid-resolver'; -import { Renderer } from '../../core/rendering/renderer'; -import { ObservabilityLoggerAdapter } from '../../core/system/observability-logger.adapter'; -import { BaseCommand } from '../shared/base.command'; - -import { NextCommandTelemetry, OperationContext } from './next.command.telemetry'; +import { EnvironmentAccessor, ProcessEnvironmentAccessor } from '../core/helpers/env.helper'; +import { isNullOrUndefined } from '../core/helpers/type.helper'; +import { Resolver } from '../core/helpers/uid-resolver'; +import { TaskHydrationService } from '../core/processing/hydrate'; +import { Renderer } from '../core/rendering/renderer'; +import { TaskProviderFactory } from '../core/storage'; +import { ObservabilityLoggerAdapter } from '../core/system/observability-logger.adapter'; +import { + CommandName, + IHydrationOptions, + ILogger, + IObservabilityLogger, + ITask, + ITaskRepository, + NextCommandOptions, + IOperationContext, + TaskProviderType, + TaskState, +} from '../types'; + +import { BaseCommand } from './base.command'; +import { NextCommandTelemetry } from './next.command.telemetry'; /** * Command for hydrating the next task. @@ -29,11 +37,17 @@ export class NextCommand extends BaseCommand { private readonly hydrationService: TaskHydrationService; private readonly observabilityLogger: IObservabilityLogger; private readonly telemetry: NextCommandTelemetry; + private readonly environment: EnvironmentAccessor; - constructor(logger: ILogger, observabilityLogger?: IObservabilityLogger) { + constructor( + logger: ILogger, + observabilityLogger?: IObservabilityLogger, + environment?: EnvironmentAccessor, + ) { super(logger); - const dddKitPath = process.env['DDDKIT_PATH'] ?? '.'; - const targetPath = process.env['TARGET_REPO_PATH'] ?? '.'; + this.environment = environment ?? new ProcessEnvironmentAccessor(); + const dddKitPath = this.environment.getOrDefault('DDDKIT_PATH', '.'); + const targetPath = this.environment.getOrDefault('TARGET_REPO_PATH', '.'); const resolver = new Resolver(dddKitPath); const renderer = new Renderer(targetPath); this.hydrationService = new TaskHydrationService(resolver, renderer, logger); @@ -45,9 +59,16 @@ export class NextCommand extends BaseCommand { /** * Executes the next command with comprehensive observability. + * @param options - The hydration options + * @returns Promise that resolves when the operation is complete + * + * @example + * ```typescript + * await command.execute({ provider: 'issues', filters: ['priority:high'], branchPrefix: 'feature/' }); + * ``` */ async execute(options: IHydrationOptions): Promise { - const op: OperationContext = this.telemetry.recordStart(this.observabilityLogger, options); + const op: IOperationContext = this.telemetry.recordStart(this.observabilityLogger, options); try { const provider = this.createProvider(options.provider ?? TaskProviderType.TASK); @@ -72,6 +93,8 @@ export class NextCommand extends BaseCommand { /** * Creates a task provider based on the provider type. + * @param providerType - The type of provider to create + * @returns the created provider instance */ private createProvider(providerType: string): ITaskRepository { let taskProviderType: TaskProviderType; @@ -88,6 +111,9 @@ export class NextCommand extends BaseCommand { /** * Finds the next eligible task. + * @param provider - The task repository provider + * @param options - The hydration options containing filters + * @returns The next eligible task or null if none found */ private async findNextTask( provider: ITaskRepository, @@ -102,6 +128,10 @@ export class NextCommand extends BaseCommand { /** * Hydrates and updates the task. + * @param task - The task to hydrate and update + * @param options - The hydration options + * @param provider - The task repository provider + * @returns Promise that resolves when the task is hydrated and updated */ private async hydrateAndUpdateTask( task: ITask, @@ -109,8 +139,8 @@ export class NextCommand extends BaseCommand { provider: ITaskRepository, ): Promise { // Get environment configuration - const dddKitPath = process.env['DDDKIT_PATH'] ?? '.'; - const targetPath = process.env['TARGET_REPO_PATH'] ?? '.'; + const dddKitPath = this.environment.getOrDefault('DDDKIT_PATH', '.'); + const targetPath = this.environment.getOrDefault('TARGET_REPO_PATH', '.'); // Hydrate the task using the injected service await this.hydrationService.hydrateTask(task, dddKitPath, targetPath, options.pin); @@ -130,6 +160,11 @@ export class NextCommand extends BaseCommand { await provider.update(updatedTask); } + /** + * Configures the next command in the CLI program. + * @param program - The commander program instance + * @param logger - The logger instance for command execution + */ static configure(program: Command, logger: ILogger): void { program .command(CommandName.NEXT) diff --git a/src/commands/ref-audit.command.test.ts b/src/commands/ref-audit.command.test.ts new file mode 100644 index 0000000..c8bc511 --- /dev/null +++ b/src/commands/ref-audit.command.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-undefined */ +import { Command } from 'commander'; + +import { container } from '../core/system/container'; +import { CommandName, ILogger, IReferenceAuditUseCase, SERVICE_KEYS } from '../types'; + +import { RefAuditCommand } from './ref-audit.command'; + +jest.mock('../core/system/container'); +jest.mock('commander'); + +describe('RefAuditCommand', () => { + let mockLogger: jest.Mocked; + let mockService: jest.Mocked; + let mockParentCommand: jest.Mocked; + + beforeEach(() => { + mockLogger = { + info: jest.fn(), + } as unknown as jest.Mocked; + + mockService = { + execute: jest.fn().mockResolvedValue(undefined), + } as jest.Mocked; + + mockParentCommand = { + command: jest.fn().mockReturnThis(), + description: jest.fn().mockReturnThis(), + action: jest.fn().mockReturnThis(), + } as unknown as jest.Mocked; + + (container.resolve as jest.Mock).mockReturnValue(mockService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + it('should execute the command successfully', async () => { + const cmd = new RefAuditCommand(mockLogger); + await cmd.execute(); + + expect(mockLogger.info).toHaveBeenCalledWith('Executing ref audit command'); + expect(container.resolve).toHaveBeenCalledWith(SERVICE_KEYS.REFERENCE_AUDIT); + expect(mockService.execute).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith('Ref audit command executed'); + }); + + it('should handle service execution error', async () => { + const error = new Error('Service error'); + mockService.execute.mockRejectedValue(error); + + const cmd = new RefAuditCommand(mockLogger); + + await expect(cmd.execute()).rejects.toThrow('Service error'); + expect(mockLogger.info).toHaveBeenCalledWith('Executing ref audit command'); + expect(mockLogger.info).not.toHaveBeenCalledWith('Ref audit command executed'); + }); + }); + + describe('configure', () => { + it('should configure the command on parent', () => { + RefAuditCommand.configure(mockParentCommand, mockLogger); + + expect(mockParentCommand.command).toHaveBeenCalledWith(CommandName.AUDIT); + expect(mockParentCommand.description).toHaveBeenCalledWith( + 'Audit references across repo & tasks', + ); + expect(mockParentCommand.action).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe('properties', () => { + it.each([ + { property: 'name', expected: CommandName.AUDIT }, + { property: 'description', expected: 'Audit references across repo & tasks' }, + ])('should have correct $property', ({ property, expected }) => { + const cmd = new RefAuditCommand(mockLogger); + expect(cmd[property as keyof RefAuditCommand]).toBe(expected); + }); + }); +}); diff --git a/src/commands/audit/ref-audit.command.ts b/src/commands/ref-audit.command.ts similarity index 62% rename from src/commands/audit/ref-audit.command.ts rename to src/commands/ref-audit.command.ts index ff22b3f..e960919 100644 --- a/src/commands/audit/ref-audit.command.ts +++ b/src/commands/ref-audit.command.ts @@ -1,10 +1,9 @@ import { Command } from 'commander'; -import { container } from '../../core/system/container'; -import { SERVICE_KEYS } from '../../types/core'; -import { ILogger } from '../../types/observability'; -import { CommandName, IReferenceAuditUseCase } from '../../types'; -import { BaseCommand } from '../shared/base.command'; +import { container } from '../core/system/container'; +import { CommandName, ILogger, IReferenceAuditUseCase, SERVICE_KEYS } from '../types'; + +import { BaseCommand } from './base.command'; /** * Command for auditing references. @@ -15,6 +14,12 @@ export class RefAuditCommand extends BaseCommand { /** * Executes the ref audit command. + * @returns Promise that resolves when the operation is complete + * + * @example + * ```typescript + * await command.execute(); + * ``` */ async execute(): Promise { this.logger.info('Executing ref audit command'); @@ -23,6 +28,11 @@ export class RefAuditCommand extends BaseCommand { this.logger.info('Ref audit command executed'); } + /** + * Configures the command within the parent command. + * @param parent - The parent Command instance + * @param logger - Logger instance for logging + */ static configure(parent: Command, logger: ILogger): void { parent .command(CommandName.AUDIT) diff --git a/src/commands/rendering/render.command.test.ts b/src/commands/render.command.test.ts similarity index 79% rename from src/commands/rendering/render.command.test.ts rename to src/commands/render.command.test.ts index 3065a3f..b72e6fd 100644 --- a/src/commands/rendering/render.command.test.ts +++ b/src/commands/render.command.test.ts @@ -1,16 +1,19 @@ /* eslint-disable no-undefined */ import { Command } from 'commander'; -import { container } from '../../core/system/container'; -import { SERVICE_KEYS } from '../../types/core'; -import { RenderCommandOptions } from '../../types/rendering'; -import { ILogger } from '../../types/observability'; -import { IRenderOptions } from '../../types/tasks'; -import { CommandName, ITaskRenderUseCase } from '../../types'; +import { container } from '../core/system/container'; +import { + CommandName, + ILogger, + IRenderOptions, + ITaskRenderUseCase, + RenderCommandOptions, + SERVICE_KEYS, +} from '../types'; import { RenderCommand } from './render.command'; -jest.mock('../../core/system/container'); +jest.mock('../core/system/container'); jest.mock('commander'); describe('RenderCommand', () => { @@ -44,25 +47,38 @@ describe('RenderCommand', () => { }); describe('execute', () => { - it('should execute render command successfully', async () => { - const command = new RenderCommand(mockLogger); - const options: IRenderOptions & { taskId: string } = { + it.each([ + { taskId: 'task-123', - pin: 'abc123', - }; + options: { pin: 'abc123' }, + description: 'with pin option', + }, + { + taskId: 'task-456', + options: {}, + description: 'without pin option', + }, + { + taskId: 'simple-task', + options: { pin: 'def789' }, + description: 'with different pin', + }, + ])('should execute render command successfully $description', async ({ taskId, options }) => { + const command = new RenderCommand(mockLogger); + const fullOptions = { taskId, ...options }; mockService.execute.mockResolvedValue(undefined); - await command.execute(options); + await command.execute(fullOptions); expect(mockLogger.info).toHaveBeenCalledWith('Executing render command', { - taskId: 'task-123', - pin: 'abc123', + taskId, + ...options, }); expect(container.resolve).toHaveBeenCalledWith(SERVICE_KEYS.TASK_RENDERER); - expect(mockService.execute).toHaveBeenCalledWith('task-123', { pin: 'abc123' }); + expect(mockService.execute).toHaveBeenCalledWith(taskId, options); expect(mockLogger.info).toHaveBeenCalledWith('Task rendered successfully', { - taskId: 'task-123', + taskId, }); expect(mockLogger.error).not.toHaveBeenCalled(); }); diff --git a/src/commands/rendering/render.command.ts b/src/commands/render.command.ts similarity index 70% rename from src/commands/rendering/render.command.ts rename to src/commands/render.command.ts index 97ad0f3..5ff64ee 100644 --- a/src/commands/rendering/render.command.ts +++ b/src/commands/render.command.ts @@ -1,12 +1,16 @@ import { Command } from 'commander'; -import { container } from '../../core/system/container'; -import { SERVICE_KEYS } from '../../types/core'; -import { RenderCommandOptions } from '../../types/rendering'; -import { ILogger } from '../../types/observability'; -import { IRenderOptions } from '../../types/tasks'; -import { CommandName, ITaskRenderUseCase } from '../../types'; -import { BaseCommand } from '../shared/base.command'; +import { container } from '../core/system/container'; +import { + CommandName, + IRenderOptions, + ITaskRenderUseCase, + SERVICE_KEYS, + ILogger, + RenderCommandOptions, +} from '../types'; + +import { BaseCommand } from './base.command'; /** * Command for rendering a specific task. @@ -17,6 +21,8 @@ export class RenderCommand extends BaseCommand { /** * Executes the render command. + * @param options - Object containing taskId and render options + * @returns Promise that resolves when the operation is complete */ async execute(options: IRenderOptions & { taskId: string }): Promise { const { taskId, ...rest } = options; @@ -32,6 +38,11 @@ export class RenderCommand extends BaseCommand { } } + /** + * Configures the render command in the CLI program. + * @param program - The commander program instance + * @param logger - The logger instance for command execution + */ static configure(program: Command, logger: ILogger): void { program .command(CommandName.RENDER) diff --git a/src/commands/shared/base.command.test.ts b/src/commands/shared/base.command.test.ts deleted file mode 100644 index f7da367..0000000 --- a/src/commands/shared/base.command.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ILogger } from '../../types'; - -import { BaseCommand } from './base.command'; - -describe('BaseCommand', () => { - class TestCommand extends BaseCommand { - name = 'test'; - description = 'test command'; - - async execute(_args?: unknown): Promise { - // Implementation for testing - } - } - - let mockLogger: jest.Mocked; - let testCommand: TestCommand; - - beforeEach(() => { - mockLogger = { - info: jest.fn(), - error: jest.fn(), - } as unknown as jest.Mocked; - testCommand = new TestCommand(mockLogger); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('constructor', () => { - it('should initialize with a logger', () => { - expect(testCommand).toBeInstanceOf(BaseCommand); - }); - }); - - describe('logInfo', () => { - it('should log to console and call logger.info', () => { - const message = 'Test info message'; - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - - testCommand['logInfo'](message); - - expect(consoleSpy).toHaveBeenCalledWith(message); - expect(mockLogger.info).toHaveBeenCalledWith(message); - - consoleSpy.mockRestore(); - }); - }); - - describe('logError', () => { - it('should call logger.error', () => { - const message = 'Test error message'; - - testCommand['logError'](message); - - expect(mockLogger.error).toHaveBeenCalledWith(message); - }); - }); -}); diff --git a/src/commands/shared/base.command.ts b/src/commands/shared/base.command.ts deleted file mode 100644 index 799c857..0000000 --- a/src/commands/shared/base.command.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ICommand, ILogger } from '../../types'; - -export abstract class BaseCommand implements ICommand { - abstract name: string; - abstract description: string; - - constructor(protected readonly logger: ILogger) {} - - protected logInfo(message: string): void { - console.log(message); - this.logger.info(message); - } - - protected logError(message: string): void { - this.logger.error(message); - } - - abstract execute(args?: unknown): Promise; -} diff --git a/src/commands/shared/command.factory.test.ts b/src/commands/shared/command.factory.test.ts deleted file mode 100644 index de6b82e..0000000 --- a/src/commands/shared/command.factory.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Command } from 'commander'; - -import { ILogger } from '../../types/observability'; -import { AddTaskCommand } from '../task-management/add-task.command'; -import { CompleteTaskCommand } from '../task-management/complete-task.command'; -import { ListTasksCommand } from '../task-management/list-tasks.command'; -import { ShowTaskCommand } from '../task-management/show-task.command'; -import { ValidateAndFixCommand } from '../validation/validate-and-fix.command'; -import { ValidateTasksCommand } from '../validation/validate-tasks.command'; -import { NextCommand } from '../rendering/next.command'; -import { RenderCommand } from '../rendering/render.command'; -import { RefAuditCommand } from '../audit/ref-audit.command'; -import { SupersedeCommand } from '../audit/supersede.command'; - -import { CommandFactory } from './command.factory'; - -// Mock chalk to handle ES module import issues -jest.mock('chalk', () => ({ - default: { - green: jest.fn((text) => text), - yellow: jest.fn((text) => text), - red: jest.fn((text) => text), - blue: jest.fn((text) => text), - bold: jest.fn((text) => text), - dim: jest.fn((text) => text), - }, - green: jest.fn((text) => text), - yellow: jest.fn((text) => text), - red: jest.fn((text) => text), - blue: jest.fn((text) => text), - bold: jest.fn((text) => text), - dim: jest.fn((text) => text), -})); - -// Mock all command modules -jest.mock('../task-management/add-task.command'); -jest.mock('../task-management/complete-task.command'); -jest.mock('../task-management/list-tasks.command'); -jest.mock('../task-management/show-task.command'); -jest.mock('../validation/validate-and-fix.command'); -jest.mock('../validation/validate-tasks.command'); -jest.mock('../rendering/next.command'); -jest.mock('../rendering/render.command'); -jest.mock('../audit/ref-audit.command'); -jest.mock('../audit/supersede.command'); - -// Import mocked command classes - -describe('CommandFactory', () => { - let mockLogger: ILogger; - let mockProgram: jest.Mocked; - let mockRefCommand: jest.Mocked; - let mockTaskCommand: jest.Mocked; - let mockValidateCommand: jest.Mocked; - - beforeEach(() => { - mockLogger = {} as ILogger; // Mock logger, assuming it's just passed through - - mockRefCommand = { - description: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - mockTaskCommand = { - description: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - mockValidateCommand = { - description: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - mockProgram = { - command: jest.fn(), - } as unknown as jest.Mocked; - - // Mock the command method to return appropriate subcommands - mockProgram.command.mockImplementation((name: string) => { - if (name === 'ref') return mockRefCommand; - if (name === 'task') return mockTaskCommand; - if (name === 'validate') return mockValidateCommand; - return mockProgram; - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should configure all commands correctly', () => { - CommandFactory.configureProgram(mockProgram, mockLogger); - - // Verify core commands are configured - expect(NextCommand.configure).toHaveBeenCalledWith(mockProgram, mockLogger); - expect(RenderCommand.configure).toHaveBeenCalledWith(mockProgram, mockLogger); - expect(SupersedeCommand.configure).toHaveBeenCalledWith(mockProgram, mockLogger); - - // Verify ref subcommand is created and configured - expect(mockProgram.command).toHaveBeenCalledWith('ref'); - expect(mockRefCommand.description).toHaveBeenCalledWith('Reference management'); - expect(RefAuditCommand.configure).toHaveBeenCalledWith(mockRefCommand, mockLogger); - - // Verify task subcommand is created and configured - expect(mockProgram.command).toHaveBeenCalledWith('task'); - expect(mockTaskCommand.description).toHaveBeenCalledWith('Task management commands'); - expect(AddTaskCommand.configure).toHaveBeenCalledWith(mockTaskCommand, mockLogger); - expect(CompleteTaskCommand.configure).toHaveBeenCalledWith(mockTaskCommand, mockLogger); - expect(ListTasksCommand.configure).toHaveBeenCalledWith(mockTaskCommand, mockLogger); - expect(ShowTaskCommand.configure).toHaveBeenCalledWith(mockTaskCommand, mockLogger); - - // Verify validate subcommand is created and configured - expect(mockProgram.command).toHaveBeenCalledWith('validate'); - expect(mockValidateCommand.description).toHaveBeenCalledWith('Validation commands'); - expect(ValidateTasksCommand.configure).toHaveBeenCalledWith(mockValidateCommand, mockLogger); - expect(ValidateAndFixCommand.configure).toHaveBeenCalledWith(mockValidateCommand, mockLogger); - }); -}); diff --git a/src/commands/shared/command.factory.ts b/src/commands/shared/command.factory.ts deleted file mode 100644 index e3fb66a..0000000 --- a/src/commands/shared/command.factory.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Command } from 'commander'; - -import { AddTaskCommand } from '../task-management/add-task.command'; -import { CompleteTaskCommand } from '../task-management/complete-task.command'; -import { ListTasksCommand } from '../task-management/list-tasks.command'; -import { ShowTaskCommand } from '../task-management/show-task.command'; -import { ValidateAndFixCommand } from '../validation/validate-and-fix.command'; -import { ValidateTasksCommand } from '../validation/validate-tasks.command'; -import { NextCommand } from '../rendering/next.command'; -import { RenderCommand } from '../rendering/render.command'; -import { RefAuditCommand } from '../audit/ref-audit.command'; -import { SupersedeCommand } from '../audit/supersede.command'; -import { ILogger } from '../../types/observability'; - -/** - * Factory for creating and configuring CLI commands following Command pattern. - * Centralizes command registration and configuration. - */ -export class CommandFactory { - /** - * Configures all commands on the given Commander program instance. - * @param program The Commander program instance to configure commands on. - */ - static configureProgram(program: Command, logger: ILogger): void { - // Core commands - NextCommand.configure(program, logger); - RenderCommand.configure(program, logger); - SupersedeCommand.configure(program, logger); - - // Reference commands - const ref = program.command('ref').description('Reference management'); - RefAuditCommand.configure(ref, logger); - - // Task commands - const task = program.command('task').description('Task management commands'); - AddTaskCommand.configure(task, logger); - CompleteTaskCommand.configure(task, logger); - ListTasksCommand.configure(task, logger); - ShowTaskCommand.configure(task, logger); - - // Validate commands - const validate = program.command('validate').description('Validation commands'); - ValidateTasksCommand.configure(validate, logger); - ValidateAndFixCommand.configure(validate, logger); - } -} diff --git a/src/commands/show-task.command.ts b/src/commands/show-task.command.ts new file mode 100644 index 0000000..41c7e24 --- /dev/null +++ b/src/commands/show-task.command.ts @@ -0,0 +1,94 @@ +import { Command } from 'commander'; + +import { ConsoleOutputWriter } from '../core/rendering'; +import { TaskManager } from '../core/storage'; +import { + CommandName, + EXIT_CODES, + ILogger, + IOutputWriter, + OutputFormat, + TaskDetails, + TodoShowCommandArgs, +} from '../types'; + +import { BaseCommand } from './base.command'; + +/** + * Modern command for showing detailed information about a specific task. + */ +export class ShowTaskCommand extends BaseCommand { + readonly name = CommandName.SHOW; + readonly description = 'Show details of a specific task'; + + constructor( + logger: ILogger, + protected override readonly outputWriter: IOutputWriter = new ConsoleOutputWriter(), + ) { + super(logger, outputWriter); + } + + /** + * Executes the show task command that displays detailed information about + * a task including its status, owner, requirements, and validations. + * @param args - Command arguments containing the task ID + * @returns Promise that resolves when the command execution is complete + */ + execute(args: TodoShowCommandArgs): Promise { + const todoManager = new TaskManager(this.logger); + const task = todoManager.findTaskById(args.id); + + if (!task) { + const message = `Task ${args.id} not found`; + this.logger.error(message, { id: args.id }); + process.exitCode = EXIT_CODES.NOT_FOUND; + return Promise.reject(message); + } + + this.logger.info('Task details displayed', { id: args.id, title: task.title }); + this.outputWriter.section(`${task.id} — ${task.title ?? 'Untitled'}`); + this.outputWriter.keyValue('Status', task.state ?? 'Unknown'); + this.outputWriter.keyValue('Owner', task.owner ?? 'Unassigned'); + this.outputWriter.newline(); + this.outputWriter.write('Detailed requirements:'); + this.outputWriter.newline(); + + try { + this.outputWriter.writeFormatted( + (task as TaskDetails).detailed_requirements ?? {}, + OutputFormat.JSON, + ); + } catch { + this.outputWriter.warning('(invalid or missing detailed_requirements)'); + this.logger.warn('Invalid detailed_requirements in task', { id: args.id }); + } + + this.outputWriter.newline(); + this.outputWriter.write('Validations:'); + this.outputWriter.newline(); + try { + this.outputWriter.writeFormatted((task as TaskDetails).validations ?? {}, OutputFormat.JSON); + } catch { + this.outputWriter.warning('(invalid or missing validations)'); + this.logger.warn('Invalid validations in task', { id: args.id }); + } + return Promise.resolve(); + } + + /** + * Configures the show task command in the CLI program. + * @param parent The parent Commander command to attach this command to. + * @param logger Logger instance for command logging. + * @param outputWriter Optional output writer for command output. + */ + static configure(parent: Command, logger: ILogger, outputWriter?: IOutputWriter): void { + parent + .command(CommandName.SHOW) + .argument('', 'Task ID to show') + .description('Show details of a specific task') + .action(async (id: string) => { + const cmd = new ShowTaskCommand(logger, outputWriter); + await cmd.execute({ id }); + }); + } +} diff --git a/src/commands/audit/supersede.command.test.ts b/src/commands/supersede.command.test.ts similarity index 83% rename from src/commands/audit/supersede.command.test.ts rename to src/commands/supersede.command.test.ts index 1eba5a4..97ab5ad 100644 --- a/src/commands/audit/supersede.command.test.ts +++ b/src/commands/supersede.command.test.ts @@ -1,14 +1,12 @@ /* eslint-disable no-undefined */ import { Command } from 'commander'; -import { container } from '../../core/system/container'; -import { SERVICE_KEYS } from '../../types/core'; -import { ILogger } from '../../types/observability'; -import { CommandName, IUIdSupersedeUseCase } from '../../types'; +import { container } from '../core/system/container'; +import { CommandName, ILogger, IUIdSupersedeUseCase, SERVICE_KEYS } from '../types'; import { SupersedeCommand } from './supersede.command'; -jest.mock('../../core/system/container'); +jest.mock('../core/system/container'); jest.mock('commander'); describe('SupersedeCommand', () => { @@ -38,10 +36,23 @@ describe('SupersedeCommand', () => { }); describe('execute', () => { - it('should execute supersede and log correctly', async () => { - const oldUid = 'OLD123'; - const newUid = 'NEW456'; - + it.each([ + { + oldUid: 'OLD123', + newUid: 'NEW456', + description: 'valid UIDs', + }, + { + oldUid: 'ABC', + newUid: 'XYZ', + description: 'short UIDs', + }, + { + oldUid: 'old-uid-123', + newUid: 'new-uid-456', + description: 'dashed UIDs', + }, + ])('should execute supersede successfully with $description', async ({ oldUid, newUid }) => { await command.execute({ oldUid, newUid }); expect(mockLogger.info).toHaveBeenCalledWith('Executing supersede command', { @@ -69,7 +80,6 @@ describe('SupersedeCommand', () => { oldUid: 'OLD123', }); expect(mockService.execute).toHaveBeenCalledWith('OLD123', 'NEW456'); - // Note: In real scenario, error logging might be handled by BaseCommand or elsewhere }); }); diff --git a/src/commands/audit/supersede.command.ts b/src/commands/supersede.command.ts similarity index 77% rename from src/commands/audit/supersede.command.ts rename to src/commands/supersede.command.ts index 0897b55..5db92ab 100644 --- a/src/commands/audit/supersede.command.ts +++ b/src/commands/supersede.command.ts @@ -1,15 +1,15 @@ import { Command } from 'commander'; -import { container } from '../../core/system/container'; -import { SERVICE_KEYS } from '../../types/core'; -import { ILogger } from '../../types/observability'; -import { CommandName, IUIdSupersedeUseCase } from '../../types'; -import { BaseCommand } from '../shared/base.command'; +import { container } from '../core/system/container'; +import { + CommandName, + ILogger, + ISupersedeOptions, + IUIdSupersedeUseCase, + SERVICE_KEYS, +} from '../types'; -interface ISupersedeOptions { - oldUid: string; - newUid: string; -} +import { BaseCommand } from './base.command'; /** * Command for superseding UIDs. @@ -35,6 +35,11 @@ export class SupersedeCommand extends BaseCommand { this.logger.info(`Supersede command executed: ${oldUid} -> ${newUid}`, { newUid, oldUid }); } + /** + * Configures the command within the parent command. + * @param parent - The parent Command instance + * @param logger - Logger instance for logging + */ static configure(parent: Command, logger: ILogger): void { parent .command(CommandName.SUPERSEDE) diff --git a/src/commands/task-management/list-tasks.command.ts b/src/commands/task-management/list-tasks.command.ts deleted file mode 100644 index 45fb71c..0000000 --- a/src/commands/task-management/list-tasks.command.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Command } from 'commander'; - -import { ILogger } from '../../types/observability'; -import { TaskManager } from '../../core/storage/task.manager'; -import { BaseCommand } from '../shared/base.command'; -import { CommandName } from '../../types'; - -/** - * Modern command for listing all tasks from the TODO.md file. - */ -export class ListTasksCommand extends BaseCommand { - readonly name = CommandName.LIST; - readonly description = 'List all tasks'; - - /** - * Executes the list tasks command. - * Retrieves all tasks from TODO.md and displays them in a formatted list. - */ - execute(): Promise { - const todoManager = new TaskManager(this.logger); - const tasks = todoManager.listTasks(); - - if (!tasks.length) { - console.log('No tasks found in TODO.md'); - return Promise.resolve(); - } - - for (const task of tasks) { - console.log(`${task.id} ${task['priority'] ?? 'P2'} ${task['summary'] ?? ''}`); - } - - return Promise.resolve(); - } - - static configure(parent: Command, logger: ILogger): void { - parent - .command(CommandName.LIST) - .description('List all tasks') - .action(async () => { - const cmd = new ListTasksCommand(logger); - await cmd.execute(); - }); - } -} diff --git a/src/commands/task-management/show-task.command.ts b/src/commands/task-management/show-task.command.ts deleted file mode 100644 index 021a452..0000000 --- a/src/commands/task-management/show-task.command.ts +++ /dev/null @@ -1,79 +0,0 @@ -import chalk from 'chalk'; -import { Command } from 'commander'; - -import { ILogger } from '../../types/observability'; -import { TaskManager } from '../../core/storage/task.manager'; -import { EXIT_CODES } from '../../constants/exit-codes'; -import { BaseCommand } from '../shared/base.command'; -import { CommandName } from '../../types'; -import { formatJson } from '../../core/parsers/json.parser'; - -interface TaskDetails { - detailed_requirements?: unknown; - validations?: unknown; -} - -/** - * Arguments for the 'todo show' command - */ -interface TodoShowCommandArgs { - /** Task ID to show */ - id: string; -} - -/** - * Modern command for showing detailed information about a specific task. - */ -export class ShowTaskCommand extends BaseCommand { - readonly name = CommandName.SHOW; - readonly description = 'Show details of a specific task'; - - /** - * Executes the show task command. - * Displays detailed information about a task including its status, owner, requirements, and validations. - */ - execute(args: TodoShowCommandArgs): Promise { - const todoManager = new TaskManager(this.logger); - const task = todoManager.findTaskById(args.id); - - if (!task) { - const message = `Task ${args.id} not found`; - this.logger.error(message, { id: args.id }); - process.exitCode = EXIT_CODES.NOT_FOUND; - return Promise.reject(message); - } - - this.logger.info('Task details displayed', { id: args.id, title: task.title }); - console.log(chalk.bold(`${task.id} — ${task.title ?? 'Untitled'}`)); - console.log(`Status: ${task.state ?? 'Unknown'}`); - console.log(`Owner: ${task.owner ?? 'Unassigned'}`); - console.log('\nDetailed requirements:'); - - try { - console.log(formatJson((task as TaskDetails).detailed_requirements ?? {})); - } catch { - console.log('(invalid or missing detailed_requirements)'); - this.logger.warn('Invalid detailed_requirements in task', { id: args.id }); - } - - console.log('\nValidations:'); - try { - console.log(formatJson((task as TaskDetails).validations ?? {})); - } catch { - console.log('(invalid or missing validations)'); - this.logger.warn('Invalid validations in task', { id: args.id }); - } - return Promise.resolve(); - } - - static configure(parent: Command, logger: ILogger): void { - parent - .command(CommandName.SHOW) - .argument('', 'Task ID to show') - .description('Show details of a specific task') - .action(async (id: string) => { - const cmd = new ShowTaskCommand(logger); - await cmd.execute({ id }); - }); - } -} diff --git a/src/commands/validation/validate-and-fix.command.ts b/src/commands/validate-and-fix.command.ts similarity index 59% rename from src/commands/validation/validate-and-fix.command.ts rename to src/commands/validate-and-fix.command.ts index 107035c..11ee66e 100644 --- a/src/commands/validation/validate-and-fix.command.ts +++ b/src/commands/validate-and-fix.command.ts @@ -1,13 +1,21 @@ import { Command } from 'commander'; -import { IValidationResult, ValidateFixCommandOptions } from '../../types/validation'; -import { ILogger } from '../../types/observability'; -import { TaskManager } from '../../core/storage/task.manager'; -import { validateAndFixTasks } from '../../validators/validator'; -import { ValidationResultRenderer } from '../../core/rendering/validation-result.renderer'; -import { isEmptyArray, isNonEmptyString } from '../../core/helpers/type-guards'; -import { EXIT_CODES } from '../../constants/exit-codes'; -import { BaseCommand } from '../shared/base.command'; +import { isEmptyArray } from '../core/helpers/array.helper'; +import { isNonEmptyString } from '../core/helpers/type.helper'; +import { ValidationResultRenderer } from '../core/rendering/validation-result.renderer'; +import { TaskManager } from '../core/storage'; +import { + CommandName, + EXIT_CODES, + ILogger, + IOutputWriter, + IValidationOptions, + IValidationResult, + ValidateFixCommandOptions, +} from '../types'; +import { validateAndFixTasks } from '../validators/validator'; + +import { BaseCommand } from './base.command'; /** * Modern command for validating tasks and optionally applying automatic fixes. @@ -17,7 +25,7 @@ import { BaseCommand } from '../shared/base.command'; * apply automatic fixes for common issues, and provide detailed reporting in various formats. */ export class ValidateAndFixCommand extends BaseCommand { - override name = 'fix'; + override name = CommandName.FIX; override description = 'Validate and fix tasks'; constructor( @@ -37,12 +45,11 @@ export class ValidateAndFixCommand extends BaseCommand { * 4. Handles different output formats (console, JSON, CSV) * 5. Provides detailed feedback about validation results and applied fixes * - * @param args - Optional runtime arguments that override constructor defaults - * @param args.fix - Whether to automatically apply fixes (overrides constructor option) - * @param args.dryRun - Whether to simulate fixes without applying them (overrides constructor option) - * @param args.summary - Output format configuration (overrides constructor option) - * @param args.summary.format - The output format: 'json' or 'csv' - * @param args.exclude - Glob pattern to exclude tasks from validation (overrides constructor option) + * @param options - Command options including fix, dryRun, format, and exclude + * - fix: boolean indicating if fixes should be applied + * - dryRun: boolean indicating if changes should be simulated without applying + * - format: output format (json, csv) + * - exclude: optional pattern to exclude certain tasks from validation/fixing * @returns Promise that resolves when the command execution is complete * * @throws Will set process.exitCode to 5 if validation errors remain after fixing @@ -54,10 +61,12 @@ export class ValidateAndFixCommand extends BaseCommand { /** * Performs the validation and fixing operation. + * @param options - Command options including fix and dryRun flags + * @returns The validation result containing errors and fix information */ private performValidation(options: ValidateFixCommandOptions) { const todoManager = new TaskManager(this.logger); - const validationOptions: Parameters[1] = { + const validationOptions: IValidationOptions = { applyFixes: Boolean(options.fix) && options.dryRun !== true, }; if (isNonEmptyString(options.exclude)) { @@ -68,6 +77,10 @@ export class ValidateAndFixCommand extends BaseCommand { /** * Handles the validation result and produces output. + * @param options - Command options including output format + * @param result - The validation result to handle + * + * @throws Will set process.exitCode to 5 if validation errors remain after fixing */ private handleValidationResult( options: ValidateFixCommandOptions, @@ -86,6 +99,9 @@ export class ValidateAndFixCommand extends BaseCommand { /** * Handles validation errors by logging them and setting exit code. + * @param errors - Array of validation error messages + * + * @throws Will set process.exitCode to 5 if validation errors remain after fixing */ private handleValidationErrors(errors: string[]): void { this.logger.error('Remaining validation errors:'); @@ -94,7 +110,24 @@ export class ValidateAndFixCommand extends BaseCommand { process.exitCode = EXIT_CODES.FIX_FAILED; } - static configure(parent: Command, logger: ILogger): void { + /** + * Configures the validate and fix command for Commander.js. + * + * Sets up the CLI interface for the validate and fix command, defining the + * command name, description, options, and action handler. This static method is + * called during application initialization to register the command. + * + * @param parent - The parent Commander.js command to attach this command to + * @param logger - Logger instance for command logging + * @param outputWriter - Optional output writer for command output + * + * @example + * ```typescript + * const program = new Command(); + * ValidateAndFixCommand.configure(program, logger, outputWriter); + * ``` + */ + static configure(parent: Command, logger: ILogger, outputWriter?: IOutputWriter): void { parent .command('fix') .description('Validate and fix tasks') @@ -103,7 +136,7 @@ export class ValidateAndFixCommand extends BaseCommand { .option('--format ', 'Output format: json, csv', 'json') .option('--exclude ', 'Pattern to exclude tasks') .action(async (options: ValidateFixCommandOptions) => { - const renderer = new ValidationResultRenderer(logger); + const renderer = new ValidationResultRenderer(logger, outputWriter); const cmd = new ValidateAndFixCommand(logger, renderer); await cmd.execute(options); }); diff --git a/src/commands/validation/validate-tasks.command.ts b/src/commands/validate-tasks.command.ts similarity index 88% rename from src/commands/validation/validate-tasks.command.ts rename to src/commands/validate-tasks.command.ts index 2daeeb1..ab5121c 100644 --- a/src/commands/validation/validate-tasks.command.ts +++ b/src/commands/validate-tasks.command.ts @@ -1,9 +1,10 @@ import { Command } from 'commander'; -import { TaskManager } from '../../core/storage/task.manager'; -import { ILogger } from '../../types/observability'; -import { validateTasks } from '../../validators/validator'; -import { BaseCommand } from '../shared/base.command'; +import { TaskManager } from '../core/storage'; +import { EXIT_CODES, ILogger, CommandName } from '../types'; +import { validateTasks } from '../validators/validator'; + +import { BaseCommand } from './base.command'; /** * Modern command for validating all tasks in TODO.md against the task schema. @@ -20,7 +21,7 @@ import { BaseCommand } from '../shared/base.command'; * ``` */ export class ValidateTasksCommand extends BaseCommand { - override name = 'validate'; + override name = CommandName.VALIDATE; override description = 'Validate all tasks'; /** @@ -56,7 +57,7 @@ export class ValidateTasksCommand extends BaseCommand { for (const error of result.errors ?? []) { this.logger.error(`- ${error}`); } - process.exitCode = 4; + process.exitCode = EXIT_CODES.VALIDATION_FAILED; return Promise.resolve(); } diff --git a/src/core/fixers/dates.fixer.ts b/src/core/fixers/dates.fixer.ts index f7253e1..49a9017 100644 --- a/src/core/fixers/dates.fixer.ts +++ b/src/core/fixers/dates.fixer.ts @@ -1,18 +1,32 @@ import { FixRecord, ITask } from '../../types/tasks'; +import { normalizeToIso } from '../helpers/date.helper'; -import { normalizeToIso, setIfChanged } from './fixer-utils'; +import { setIfChangedImmutable } from './fixer-utils'; +/** + * Fixes a date field ('created' or 'updated') on a task object to ensure it is in ISO format. + * If the current value is not in ISO format, it will be normalized to ISO using the provided current time as reference. + * Records any changes made in the fixes array. + * + * @param params - Parameters for fixing the date field + * @param params.nowIso - The current time in ISO format to use as reference for normalization + * @param params.asObj - The task object containing the date field to fix + * @param params.field - The specific date field to fix ('created' or 'updated') + * @param params.fixes - Array to record any fixes made + * @param params.id - The ID of the task being fixed (for logging purposes) + * @returns A new task object with the fixed date field, or the original object if no change was needed + */ export function fixDateField(params: { nowIso: string; - asObj: ITask; + task: ITask; field: 'created' | 'updated'; fixes: FixRecord[]; id: string; -}): void { - const { nowIso, asObj, field, fixes, id } = params; +}): ITask { + const { nowIso, task, field, fixes, id } = params; // eslint-disable-next-line security/detect-object-injection - const current = String((asObj as Record)[field] ?? ''); + const current = String(task[field] ?? ''); const normalized = normalizeToIso(nowIso, current); - if (current === normalized) return; - setIfChanged({ asObj, field, next: normalized, fixes, id }); + if (current === normalized) return task; + return setIfChangedImmutable({ task, field, next: normalized, fixes, id }); } diff --git a/src/core/fixers/fixer-utils.ts b/src/core/fixers/fixer-utils.ts index 33d7fcc..be57261 100644 --- a/src/core/fixers/fixer-utils.ts +++ b/src/core/fixers/fixer-utils.ts @@ -1,30 +1,28 @@ import { FixRecord, ITask } from '../../types/tasks'; -import { isEmptyString, isNullOrUndefined, isString } from '../helpers/type-guards'; -/** Safely set a field if it changed and push a fix record. */ -export function setIfChanged(params: { - asObj: ITask; +/** + * Creates a new task object with a field updated if the new value is different from the current value. + * Records the change in the fixes array. + * @param params - Parameters for the operation. + * @param params.asObj - The original task object (not modified). + * @param params.field - The field of the task to potentially update. + * @param params.next - The new value to set. + * @param params.fixes - Array to record any changes made. + * @param params.id - The ID of the task being modified (for logging purposes). + * @returns A new task object with the field updated, or the original object if no change was needed. + */ +export function setIfChangedImmutable(params: { + task: ITask; field: keyof ITask; next: unknown; fixes: FixRecord[]; id: string; -}): void { - const { asObj, field, next, fixes, id } = params; +}): ITask { + const { task, field, next, fixes, id } = params; // eslint-disable-next-line security/detect-object-injection - const current = asObj[field]; - if (current === next) return; + const current = task[field]; + if (current === next) return task; fixes.push({ field: String(field), id, new: next as string, old: current as string }); - // eslint-disable-next-line security/detect-object-injection - asObj[field] = next; -} - -function isValidDate(value: string | undefined): boolean { - if (isNullOrUndefined(value) || isEmptyString(value)) return false; - const t = Date.parse(value); - return !Number.isNaN(t); -} - -export function normalizeToIso(nowIso: string, value: string | undefined): string { - if (!isString(value) || !isValidDate(value)) return nowIso; - return new Date(value).toISOString(); + // Create a new object with the updated field + return { ...task, [field]: next }; } diff --git a/src/core/fixers/owner.fixer.ts b/src/core/fixers/owner.fixer.ts index 3e922de..edbb220 100644 --- a/src/core/fixers/owner.fixer.ts +++ b/src/core/fixers/owner.fixer.ts @@ -1,11 +1,24 @@ import { FixRecord, ITask } from '../../types/tasks'; -import { setIfChanged } from './fixer-utils'; +import { setIfChangedImmutable } from './fixer-utils'; -export function fixOwner(asObj: ITask, fixes: FixRecord[], id: string): void { - const raw = String((asObj as Record)['owner'] ?? ''); +/** + * Normalizes and fixes the 'owner' field of a task. + * + * This function trims whitespace, collapses multiple spaces, and capitalizes + * each word in the 'owner' field of the given task object. If the normalized + * value differs from the original, it returns a new task object with the updated field + * and records the change in the provided fixes array. + * + * @param task - The original task object (not modified). + * @param fixes - Array to record any changes made. + * @param id - The ID of the task being modified (for logging purposes). + * @returns A new task object with the fixed owner field, or the original object if no change was needed. + */ +export function fixOwner(task: ITask, fixes: FixRecord[], id: string): ITask { + const raw = String(task.owner ?? ''); const trimmed = raw.trim(); - if (trimmed === '') return; + if (trimmed === '') return task; const collapsed = trimmed.replace(/\s+/g, ' '); const title = collapsed @@ -14,6 +27,6 @@ export function fixOwner(asObj: ITask, fixes: FixRecord[], id: string): void { .map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()) .join(' '); - if (title === raw) return; - setIfChanged({ asObj, field: 'owner', next: title, fixes, id }); + if (title === raw) return task; + return setIfChangedImmutable({ task, field: 'owner', next: title, fixes, id }); } diff --git a/src/core/fixers/priority.fixer.ts b/src/core/fixers/priority.fixer.ts index 369997c..8f94036 100644 --- a/src/core/fixers/priority.fixer.ts +++ b/src/core/fixers/priority.fixer.ts @@ -1,11 +1,20 @@ import { FixRecord, ITask, TaskPriority } from '../../types/tasks'; -import { setIfChanged } from './fixer-utils'; +import { setIfChangedImmutable } from './fixer-utils'; -export function fixPriority(asObj: ITask, fixes: FixRecord[], id: string): void { - const raw = String((asObj as Record)['priority'] ?? ''); - const valid = Object.values(TaskPriority) as string[]; - const isValid = raw !== '' && valid.includes(raw); - if (isValid) return; - setIfChanged({ asObj, field: 'priority', next: TaskPriority.P2, fixes, id }); +const VALID: string[] = Object.values(TaskPriority); + +/** + * Ensures the task has a valid priority; returns a new task with P2 if missing or invalid. + * + * @param task - The original task object (not modified). + * @param fixes - Array to record any fixes made. + * @param id - The ID of the task (for logging purposes). + * @returns A new task object with the fixed priority field, or the original object if no change was needed. + */ +export function fixPriority(task: ITask, fixes: FixRecord[], id: string): ITask { + const raw = String(task.priority ?? ''); + const isValid = raw !== '' && VALID.includes(raw); + if (isValid) return task; + return setIfChangedImmutable({ task, field: 'priority', next: TaskPriority.P2, fixes, id }); } diff --git a/src/core/fixers/status.fixer.ts b/src/core/fixers/status.fixer.ts index 37d8530..6e1fa68 100644 --- a/src/core/fixers/status.fixer.ts +++ b/src/core/fixers/status.fixer.ts @@ -1,12 +1,20 @@ import { FixRecord, ITask, TaskStatus } from '../../types/tasks'; -import { setIfChanged } from './fixer-utils'; +import { setIfChangedImmutable } from './fixer-utils'; -const VALID: TaskStatus[] = [TaskStatus.Open, TaskStatus.Closed, TaskStatus.InReview]; +const VALID: string[] = [TaskStatus.Open, TaskStatus.Closed, TaskStatus.InReview]; -export function fixStatus(asObj: ITask, fixes: FixRecord[], id: string): void { - const raw = String((asObj as Record)['status'] ?? ''); - const isValid = raw !== '' && (VALID as string[]).includes(raw); - if (isValid) return; - setIfChanged({ asObj, field: 'status', next: TaskStatus.Open, fixes, id }); +/** + * Ensures the task has a valid status; returns a new task with 'open' if missing or invalid. + * + * @param task - The original task object (not modified). + * @param fixes - Array to record any fixes made. + * @param id - The ID of the task (for logging purposes). + * @returns A new task object with the fixed status field, or the original object if no change was needed. + */ +export function fixStatus(task: ITask, fixes: FixRecord[], id: string): ITask { + const raw = String(task.status ?? ''); + const isValid = raw !== '' && VALID.includes(raw); + if (isValid) return task; + return setIfChangedImmutable({ task, field: 'status', next: TaskStatus.Open, fixes, id }); } diff --git a/src/core/fixers/task.fixer.ts b/src/core/fixers/task.fixer.ts index 15808d0..d3f1922 100644 --- a/src/core/fixers/task.fixer.ts +++ b/src/core/fixers/task.fixer.ts @@ -1,9 +1,9 @@ -import { FixRecord, IFixerOptions, ITask } from '../../types/tasks'; +import { FixRecord, IFixerOptions, ITask, TaskFixResult } from '../../types/tasks'; -import { fixPriority } from './priority.fixer'; -import { fixStatus } from './status.fixer'; import { fixDateField } from './dates.fixer'; import { fixOwner } from './owner.fixer'; +import { fixPriority } from './priority.fixer'; +import { fixStatus } from './status.fixer'; /** * Class responsible for applying automatic fixes to task objects that have validation issues. @@ -22,19 +22,32 @@ export class TaskFixer { /** * Applies basic automatic fixes to common validation issues in a task object. - * @param asObj - The task object to fix (as a record). - * @returns An array of FixRecord objects describing the fixes applied. + * @param task - The original task object (not modified). + * @returns An object containing the fixed task and an array of FixRecord objects describing the fixes applied. */ - applyBasicFixes(asObj: ITask): FixRecord[] { + applyBasicFixes(task: ITask): TaskFixResult { const fixes: FixRecord[] = []; - const id = String(asObj.id); + const id = String(task.id); - fixPriority(asObj, fixes, id); - fixStatus(asObj, fixes, id); - fixDateField({ nowIso: this.nowIso, asObj, field: 'created', fixes, id }); - fixDateField({ nowIso: this.nowIso, asObj, field: 'updated', fixes, id }); - fixOwner(asObj, fixes, id); + // Chain the immutable fixes + let fixedTask = fixPriority(task, fixes, id); + fixedTask = fixStatus(fixedTask, fixes, id); + fixedTask = fixDateField({ + nowIso: this.nowIso, + task: fixedTask, + field: 'created', + fixes, + id, + }); + fixedTask = fixDateField({ + nowIso: this.nowIso, + task: fixedTask, + field: 'updated', + fixes, + id, + }); + fixedTask = fixOwner(fixedTask, fixes, id); - return fixes; + return { fixedTask, fixes }; } } diff --git a/src/core/helpers/array.helper.test.ts b/src/core/helpers/array.helper.test.ts new file mode 100644 index 0000000..475b904 --- /dev/null +++ b/src/core/helpers/array.helper.test.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-undefined */ +import { isEmptyArray } from './array.helper'; + +describe('array.helper', () => { + describe('isEmptyArray', () => { + const cases: ReadonlyArray<{ value: unknown; expected: boolean; description: string }> = [ + { value: [], expected: true, description: 'an empty array' }, + { value: [1, 2, 3], expected: false, description: 'a non-empty array' }, + { value: '', expected: false, description: 'a string' }, + { value: {}, expected: false, description: 'an object' }, + { value: null, expected: false, description: 'null' }, + { value: undefined, expected: false, description: 'undefined' }, + { value: 0, expected: false, description: 'a number' }, + ]; + + it.each(cases)('returns $expected for $description', ({ value, expected }) => { + expect(isEmptyArray(value)).toBe(expected); + }); + }); +}); diff --git a/src/core/helpers/array.helper.ts b/src/core/helpers/array.helper.ts new file mode 100644 index 0000000..d8228a3 --- /dev/null +++ b/src/core/helpers/array.helper.ts @@ -0,0 +1,8 @@ +/** + * Type guard to check if a value is an empty array + * @param value The value to check + * @returns True if the value is an empty array, false otherwise + */ +export function isEmptyArray(value: unknown): boolean { + return Array.isArray(value) && value.length === 0; +} diff --git a/src/core/helpers/date.helper.test.ts b/src/core/helpers/date.helper.test.ts new file mode 100644 index 0000000..7eb8025 --- /dev/null +++ b/src/core/helpers/date.helper.test.ts @@ -0,0 +1,55 @@ +import { normalizeToIso } from './date.helper'; + +describe('date.helper', () => { + const validDateInputs = [ + ['2023-01-01'], + ['2023-01-01T00:00:00Z'], + ['2023-12-31T23:59:59.999Z'], + ['2020-02-29'], // Leap year + ] as const; + + const invalidDateInputs = [ + [void 0], + [''], + ['invalid-date'], + ['2023-13-01'], // Invalid month + ['2023-01-32'], // Invalid day + ['not-a-date'], + ] as const; + + it.each(validDateInputs)('isValidDate returns true for %s', (value) => { + const result = !Number.isNaN(Date.parse(value)); + expect(result).toBe(true); + }); + + it.each(invalidDateInputs)('isValidDate returns false for %s', (value) => { + const result = + value === void 0 || + value === '' || + (typeof value === 'string' && Number.isNaN(Date.parse(value))); + expect(result).toBe(true); + }); + + describe('normalizeToIso', () => { + const nowIso = '2023-01-01T00:00:00.000Z'; + + const normalizePositiveCases = [ + ['2023-01-01', '2023-01-01T00:00:00.000Z'], + ['2023-01-01T12:30:45Z', '2023-01-01T12:30:45.000Z'], + ['2023-12-31T23:59:59.999Z', '2023-12-31T23:59:59.999Z'], + ['2020-02-29', '2020-02-29T00:00:00.000Z'], // Leap year + ] as const; + + const normalizeNegativeCases = invalidDateInputs; + + it.each(normalizePositiveCases)('normalizeToIso converts %s to %s', (value, expected) => { + const result = normalizeToIso(nowIso, value); + expect(result).toBe(expected); + }); + + it.each(normalizeNegativeCases)('normalizeToIso falls back to nowIso for %s', (value) => { + const result = normalizeToIso(nowIso, value); + expect(result).toBe(nowIso); + }); + }); +}); diff --git a/src/core/helpers/date.helper.ts b/src/core/helpers/date.helper.ts new file mode 100644 index 0000000..18a4d28 --- /dev/null +++ b/src/core/helpers/date.helper.ts @@ -0,0 +1,23 @@ +import { isEmptyString, isNullOrUndefined, isString } from './type.helper'; + +/** + * Checks if a string is a valid date. + * @param value The string to check. + * @returns True if the string is a valid date, false otherwise. + */ +function isValidDate(value: string | undefined): boolean { + if (isNullOrUndefined(value) || isEmptyString(value)) return false; + const t = Date.parse(value); + return !Number.isNaN(t); +} + +/** + * Normalizes a date string to ISO format, or returns the current date in ISO format if invalid. + * @param nowIso The current date in ISO format to use as fallback. + * @param value The date string to normalize. + * @returns The normalized date string in ISO format, or nowIso if the input is invalid. + */ +export function normalizeToIso(nowIso: string, value: string | undefined): string { + if (!isString(value) || !isValidDate(value)) return nowIso; + return new Date(value).toISOString(); +} diff --git a/src/core/helpers/env.helper.test.ts b/src/core/helpers/env.helper.test.ts new file mode 100644 index 0000000..be1b236 --- /dev/null +++ b/src/core/helpers/env.helper.test.ts @@ -0,0 +1,107 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable no-undefined */ +import { ProcessEnvironmentAccessor } from './env.helper'; + +describe('ProcessEnvironmentAccessor', () => { + let accessor: ProcessEnvironmentAccessor; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should use provided env object', () => { + const mockEnv: Record = { TEST_KEY: 'test_value' }; + accessor = new ProcessEnvironmentAccessor(mockEnv); + expect(accessor.get('TEST_KEY')).toBe('test_value'); + }); + + it('should default to process.env if no env provided', () => { + accessor = new ProcessEnvironmentAccessor(); + expect(accessor).toBeInstanceOf(ProcessEnvironmentAccessor); + }); + }); + + describe('get', () => { + beforeEach(() => { + const mockEnv: Record = { + EXISTING_KEY: 'value', + EMPTY_KEY: '', + UNDEFINED_KEY: undefined as string | undefined, + }; + accessor = new ProcessEnvironmentAccessor(mockEnv); + }); + + it('should return the value if key exists and is non-empty string', () => { + expect(accessor.get('EXISTING_KEY')).toBe('value'); + }); + + it('should return undefined if key exists but value is empty string', () => { + expect(accessor.get('EMPTY_KEY')).toBeUndefined(); + }); + + it('should return undefined if key does not exist', () => { + expect(accessor.get('NON_EXISTING_KEY')).toBeUndefined(); + }); + + it('should return undefined if key exists but value is undefined', () => { + expect(accessor.get('UNDEFINED_KEY')).toBeUndefined(); + }); + }); + + describe('getOrDefault', () => { + beforeEach(() => { + const mockEnv: Record = { + EXISTING_KEY: 'value', + EMPTY_KEY: '', + }; + accessor = new ProcessEnvironmentAccessor(mockEnv); + }); + + it('should return the value if key exists and is non-empty', () => { + expect(accessor.getOrDefault('EXISTING_KEY', 'default')).toBe('value'); + }); + + it('should return default if key exists but value is empty', () => { + expect(accessor.getOrDefault('EMPTY_KEY', 'default')).toBe('default'); + }); + + it('should return default if key does not exist', () => { + expect(accessor.getOrDefault('NON_EXISTING_KEY', 'default')).toBe('default'); + }); + }); + + describe('require', () => { + beforeEach(() => { + const mockEnv: Record = { + EXISTING_KEY: 'value', + EMPTY_KEY: '', + }; + accessor = new ProcessEnvironmentAccessor(mockEnv); + }); + + it('should return the value if key exists and is non-empty', () => { + expect(accessor.require('EXISTING_KEY')).toBe('value'); + }); + + it('should return default if key exists but value is empty and default is provided', () => { + expect(accessor.require('EMPTY_KEY', 'default')).toBe('default'); + }); + + it('should return default if key does not exist and default is provided', () => { + expect(accessor.require('NON_EXISTING_KEY', 'default')).toBe('default'); + }); + + it('should throw error if key exists but value is empty and no default provided', () => { + expect(() => accessor.require('EMPTY_KEY')).toThrow( + "Environment variable 'EMPTY_KEY' is not set or empty", + ); + }); + + it('should throw error if key does not exist and no default provided', () => { + expect(() => accessor.require('NON_EXISTING_KEY')).toThrow( + "Environment variable 'NON_EXISTING_KEY' is not set or empty", + ); + }); + }); +}); diff --git a/src/core/helpers/env.helper.ts b/src/core/helpers/env.helper.ts new file mode 100644 index 0000000..cec892f --- /dev/null +++ b/src/core/helpers/env.helper.ts @@ -0,0 +1,58 @@ +import { isNonEmptyString } from './type.helper'; + +/** + * Contract for environment access. Enables test doubles to control env lookups. + */ +export interface EnvironmentAccessor { + get(key: string): string | undefined; + getOrDefault(key: string, defaultValue: string): string; + require(key: string, defaultValue?: string): string; +} + +/** + * Default implementation that reads from process.env. + */ +export class ProcessEnvironmentAccessor implements EnvironmentAccessor { + constructor(private readonly env: Record = process.env) {} + + get(key: string): string | undefined { + // eslint-disable-next-line security/detect-object-injection + const value = this.env[key]; + return isNonEmptyString(value) ? value : void 0; + } + + getOrDefault(key: string, defaultValue: string): string { + return this.get(key) ?? defaultValue; + } + + require(key: string, defaultValue?: string): string { + const value = this.get(key); + if (isNonEmptyString(value)) { + return value; + } + if (defaultValue !== void 0) { + return defaultValue; + } + throw new Error(`Environment variable '${key}' is not set or empty`); + } +} + +const defaultEnvironmentAccessor = new ProcessEnvironmentAccessor(); + +/** + * Safe environment variable accessor + * @param key The environment variable key + * @param defaultValue Optional default value if the env var is not set + * @returns The environment variable value or the default value + * @throws Error if the env var is not set and no default is provided + */ +export function safeEnv(key: string, defaultValue?: string): string { + return defaultEnvironmentAccessor.require(key, defaultValue); +} + +/** + * Safe environment variable accessor that returns undefined if not set + */ +export function safeEnvOptional(key: string): string | undefined { + return defaultEnvironmentAccessor.get(key); +} diff --git a/src/core/helpers/object.helper.test.ts b/src/core/helpers/object.helper.test.ts new file mode 100644 index 0000000..14037ed --- /dev/null +++ b/src/core/helpers/object.helper.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable max-nested-callbacks */ +import { safeGet, safeGetRequired } from './object.helper'; + +describe('object.helper', () => { + describe('safeGet', () => { + const negativeCases: ReadonlyArray<{ description: string; obj: unknown; key: string }> = [ + { description: 'null object', obj: null, key: 'key' }, + { description: 'undefined object', obj: void 0, key: 'key' }, + { description: 'string input', obj: 'string', key: 'key' }, + { description: 'number input', obj: 42, key: 'key' }, + { description: 'array input', obj: [], key: 'key' }, + { description: 'missing key', obj: { a: 1 }, key: 'b' }, + ]; + + const positiveCases: ReadonlyArray<{ + description: string; + obj: Record; + key: string; + expected: unknown; + }> = [ + { + description: 'existing numeric property', + obj: { a: 1, b: 'test' }, + key: 'a', + expected: 1, + }, + { + description: 'existing string property', + obj: { a: 1, b: 'test' }, + key: 'b', + expected: 'test', + }, + { + description: 'nested object property', + obj: { nested: { value: 'deep' } }, + key: 'nested', + expected: { value: 'deep' }, + }, + ]; + + it.each(negativeCases)('returns undefined for $description', ({ obj, key }) => { + expect(safeGet(obj as Record | null | undefined, key)).toBeUndefined(); + }); + + it.each(positiveCases)('returns expected value for $description', ({ obj, key, expected }) => { + expect(safeGet(obj, key)).toEqual(expected); + }); + }); + + describe('safeGetRequired', () => { + const negativeCases: ReadonlyArray<{ + description: string; + obj: unknown; + key: string; + message?: string; + }> = [ + { description: 'null object', obj: null, key: 'key' }, + { description: 'undefined object', obj: void 0, key: 'key' }, + { description: 'non-object input', obj: 'string', key: 'key' }, + { description: 'missing key with default message', obj: { a: 1 }, key: 'b' }, + { + description: 'missing key with custom message', + obj: { a: 1 }, + key: 'b', + message: 'Custom error', + }, + ]; + + const positiveCases: ReadonlyArray<{ + description: string; + obj: Record; + key: string; + expected: unknown; + }> = [ + { + description: 'existing numeric property', + obj: { a: 1, b: 'test' }, + key: 'a', + expected: 1, + }, + { + description: 'existing string property', + obj: { a: 1, b: 'test' }, + key: 'b', + expected: 'test', + }, + { + description: 'nested object property', + obj: { nested: { value: 'deep' } }, + key: 'nested', + expected: { value: 'deep' }, + }, + ]; + + it.each(negativeCases)('throws when $description', ({ obj, key, message }) => { + const expectedMessage = message ?? `Required property '${key}' is missing`; + expect(() => + safeGetRequired(obj as Record | null | undefined, key, message), + ).toThrow(expectedMessage); + }); + + it.each(positiveCases)('returns expected value when $description', ({ obj, key, expected }) => { + expect(safeGetRequired(obj, key)).toEqual(expected); + }); + }); +}); diff --git a/src/core/helpers/object.helper.ts b/src/core/helpers/object.helper.ts new file mode 100644 index 0000000..8a28af0 --- /dev/null +++ b/src/core/helpers/object.helper.ts @@ -0,0 +1,38 @@ +import { isObject } from './type.helper'; + +/** + * Safe property accessor for objects with index signatures + * @param obj The object to access + * @param key The property key to access + * @returns The property value or undefined if not found or obj is not an object + */ +export function safeGet( + obj: Record | undefined | null, + key: string, +): T | undefined { + if (!isObject(obj)) { + return void 0; + } + // eslint-disable-next-line security/detect-object-injection + return obj[key] as T | undefined; +} + +/** + * Safe property accessor that throws if property doesn't exist + * @param obj The object to access + * @param key The property key to access + * @param errorMessage Optional custom error message if the property is missing + * @returns The property value + * @throws Error if the property is missing + */ +export function safeGetRequired( + obj: Record | undefined | null, + key: string, + errorMessage = `Required property '${key}' is missing`, +): T { + const value = safeGet(obj, key); + if (value === void 0) { + throw new Error(errorMessage); + } + return value; +} diff --git a/src/core/helpers/type-guards.ts b/src/core/helpers/type-guards.ts deleted file mode 100644 index 75b505a..0000000 --- a/src/core/helpers/type-guards.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Type guards and utility functions for defensive programming - */ - -import { ITask } from '../../types'; - -/** - * Type guard to check if a value is a string - */ -export function isString(value: unknown): value is string { - return typeof value === 'string'; -} - -/** - * Type guard to check if a value is an object (not null, not array) - */ -export function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Type guard to check if a value is an empty array - */ -export function isEmptyArray(value: unknown): boolean { - return Array.isArray(value) && value.length === 0; -} - -/** - * Type guard to check if a value is an empty string - */ -export function isEmptyString(value: unknown): value is '' { - return value === ''; -} - -/** - * Type guard to check if a value is a non-empty string - */ -export function isNonEmptyString(value: unknown): value is string { - return isString(value) && !isEmptyString(value.trim()); -} - -/** - * Type guard to check if a value is null or undefined - */ -export function isNullOrUndefined(value: unknown): value is null | undefined { - return value === null || typeof value === 'undefined'; -} - -/** Type guard to ensure a value conforms to ITask minimally by id being a string. */ -export function isTask(value: unknown): value is ITask { - if (isNullOrUndefined(value) || !isObject(value)) return false; - const obj = value as { id?: string }; - return isNonEmptyString(obj.id); -} - -/** - * Safe property accessor for objects with index signatures - */ -export function safeGet( - obj: Record | undefined | null, - key: string, -): T | undefined { - if (!isObject(obj)) { - return void 0; - } - // eslint-disable-next-line security/detect-object-injection - return obj[key] as T | undefined; -} - -/** - * Safe property accessor that throws if property doesn't exist - */ -export function safeGetRequired( - obj: Record | undefined | null, - key: string, - errorMessage = `Required property '${key}' is missing`, -): T { - const value = safeGet(obj, key); - if (value === void 0) { - throw new Error(errorMessage); - } - return value; -} - -/** - * Safe environment variable accessor - */ -export function safeEnv(key: string, defaultValue?: string): string { - // eslint-disable-next-line security/detect-object-injection - const value = process.env[key]; - if (isNonEmptyString(value)) { - return value; - } - if (defaultValue !== void 0) { - return defaultValue; - } - throw new Error(`Environment variable '${key}' is not set or empty`); -} - -/** - * Safe environment variable accessor that returns undefined if not set - */ -export function safeEnvOptional(key: string): string | undefined { - // eslint-disable-next-line security/detect-object-injection - const value = process.env[key]; - return isNonEmptyString(value) ? value : void 0; -} diff --git a/src/core/helpers/type.helper.test.ts b/src/core/helpers/type.helper.test.ts new file mode 100644 index 0000000..27c8cab --- /dev/null +++ b/src/core/helpers/type.helper.test.ts @@ -0,0 +1,119 @@ +/* eslint-disable no-undefined */ +import { + isString, + isObject, + isEmptyString, + isNonEmptyString, + isNullOrUndefined, + isTask, +} from './type.helper'; + +describe('type.helper', () => { + describe('isString', () => { + it.each([ + ['hello', true], + ['', true], + [' ', true], + [123, false], + [null, false], + [undefined, false], + [{}, false], + [[], false], + [true, false], + [new Date(), false], + ])('should return %s for %p', (value, expected) => { + expect(isString(value)).toBe(expected); + }); + }); + + describe('isObject', () => { + it.each([ + [{}, true], + [{ key: 'value' }, true], + [{ nested: { obj: true } }, true], + [null, false], + [undefined, false], + ['string', false], + [123, false], + [[], false], + [new Date(), true], + [true, false], + [() => {}, false], + ])('should return %s for %p', (value, expected) => { + expect(isObject(value)).toBe(expected); + }); + }); + + describe('isEmptyString', () => { + it.each([ + ['', true], + ['a', false], + [' ', false], + [null, false], + [undefined, false], + [123, false], + [{}, false], + [[], false], + [true, false], + ])('should return %s for %p', (value, expected) => { + expect(isEmptyString(value)).toBe(expected); + }); + }); + + describe('isNonEmptyString', () => { + it.each([ + ['hello', true], + [' hello ', true], + ['a', true], + [' x ', true], + ['', false], + [' ', false], + [null, false], + [undefined, false], + [123, false], + [{}, false], + [[], false], + [true, false], + ])('should return %s for %p', (value, expected) => { + expect(isNonEmptyString(value)).toBe(expected); + }); + }); + + describe('isNullOrUndefined', () => { + it.each([ + [null, true], + [undefined, true], + ['', false], + [0, false], + [false, false], + [{}, false], + [[], false], + ['string', false], + [123, false], + ])('should return %s for %p', (value, expected) => { + expect(isNullOrUndefined(value)).toBe(expected); + }); + }); + + describe('isTask', () => { + it.each([ + [{ id: '123', title: 'Test Task' }, true], + [{ id: 'abc', description: 'Another task' }, true], + [{ id: ' valid ', other: 'prop' }, true], + [null, false], + [undefined, false], + ['string', false], + [{}, false], + [{ id: '' }, false], + [{ id: ' ' }, false], + [{ id: 123 }, false], + [{ id: null }, false], + [{ id: undefined }, false], + [{ title: 'No id' }, false], + [[], false], + [true, false], + ])('should return %s for %p', (value, expected) => { + expect(isTask(value)).toBe(expected); + }); + }); +}); diff --git a/src/core/helpers/type.helper.ts b/src/core/helpers/type.helper.ts new file mode 100644 index 0000000..d3d9a0f --- /dev/null +++ b/src/core/helpers/type.helper.ts @@ -0,0 +1,59 @@ +/** + * Type guards and utility functions for defensive programming + */ + +import { ITask } from '../../types'; + +/** + * Type guard to check if a value is a string + */ +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +/** + * Type guard to check if a value is an object (not null, not array) + */ +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Type guard to check if a value is an empty string + * @param value The value to check + * @returns True if the value is an empty string, false otherwise + */ +export function isEmptyString(value: unknown): value is '' { + return value === ''; +} + +/** + * Type guard to check if a value is a non-empty string + * @param value The value to check + * @returns True if the value is a non-empty string, false otherwise + */ +export function isNonEmptyString(value: unknown): value is string { + return isString(value) && !isEmptyString(value.trim()); +} + +/** + * Type guard to check if a value is null or undefined + * @param value The value to check + * @returns True if the value is null or undefined, false otherwise + */ +export function isNullOrUndefined(value: unknown): value is null | undefined { + return value === null || typeof value === 'undefined'; +} + +/** + * Type guard to ensure a value conforms to ITask minimally by id being a string. + * @param value The value to check + * @returns True if the value is an ITask, false otherwise + */ +export function isTask(value: unknown): value is ITask { + if (isNullOrUndefined(value) || !isObject(value)) { + return false; + } + const obj = value as { id?: string }; + return isNonEmptyString(obj.id); +} diff --git a/src/core/helpers/uid-resolver.test.ts b/src/core/helpers/uid-resolver.test.ts new file mode 100644 index 0000000..caf801c --- /dev/null +++ b/src/core/helpers/uid-resolver.test.ts @@ -0,0 +1,161 @@ +import * as path from 'path'; + +import { parseJsonFile } from '../parsers/json.parser'; +import { FileManager } from '../storage'; + +import { safeGet } from './object.helper'; +import { Resolver } from './uid-resolver'; +import { isString } from './type.helper'; + +// Mock dependencies +jest.mock('../storage'); +jest.mock('../parsers/json.parser'); +jest.mock('./object.helper'); +jest.mock('./type.helper'); + +describe('Resolver', () => { + let resolver: Resolver; + + const dddKitPath = '/path/to/ddd-kit'; + const mockFileManager = FileManager as jest.MockedClass; + const mockParseJsonFile = parseJsonFile as jest.MockedFunction; + const mockSafeGet = safeGet as jest.MockedFunction; + const mockIsString = isString as jest.MockedFunction; + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + mockFileManager.prototype.existsSync.mockReturnValue(true); + mockFileManager.prototype.readFileSync.mockReturnValue('content'); + mockParseJsonFile.mockReturnValue({}); + mockSafeGet.mockReturnValue(void 0); + mockIsString.mockReturnValue(false); + }); + + describe('constructor', () => { + it('should initialize and load catalogs', () => { + const registryPath = path.join(dddKitPath, 'standards', 'catalogs', 'registry.json'); + const aliasesPath = path.join(dddKitPath, 'standards', 'catalogs', 'aliases.json'); + + new Resolver(dddKitPath); + + expect(mockFileManager.prototype.existsSync).toHaveBeenCalledWith(registryPath); + expect(mockFileManager.prototype.existsSync).toHaveBeenCalledWith(aliasesPath); + expect(mockParseJsonFile).toHaveBeenCalledWith(registryPath, expect.any(FileManager)); + expect(mockParseJsonFile).toHaveBeenCalledWith(aliasesPath, expect.any(FileManager)); + }); + }); + + describe('resolve', () => { + beforeEach(() => { + resolver = new Resolver(dddKitPath); + }); + + it('should return null if UID not in registry', () => { + mockSafeGet.mockReturnValueOnce(void 0); // for aliases + mockSafeGet.mockReturnValueOnce(void 0); // for registry + + const result = resolver.resolve('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should resolve UID with alias', () => { + const entry = { path: 'some/path', status: 'active', requires: [] }; + mockSafeGet.mockReturnValueOnce('actualUid'); // alias + mockIsString.mockReturnValueOnce(true); + mockSafeGet.mockReturnValueOnce(entry); // registry + mockFileManager.prototype.existsSync.mockReturnValue(true); + + const result = resolver.resolve('aliasUid'); + + expect(result).toEqual({ content: 'content', path: 'some/path', status: 'active' }); + }); + + it('should resolve UID without alias', () => { + const entry = { path: 'some/path', status: 'active', requires: [] }; + mockSafeGet.mockReturnValueOnce(void 0); // no alias + mockSafeGet.mockReturnValueOnce(entry); // registry + + const result = resolver.resolve('directUid'); + + expect(result).toEqual({ content: 'content', path: 'some/path', status: 'active' }); + }); + + it('should return null if file does not exist', () => { + const entry = { path: 'some/path', status: 'active', requires: [] }; + mockSafeGet.mockReturnValueOnce(void 0); + mockSafeGet.mockReturnValueOnce(entry); + mockFileManager.prototype.existsSync.mockReturnValue(false); + + const result = resolver.resolve('uid'); + + expect(result).toBeNull(); + }); + }); + + describe('getRequires', () => { + beforeEach(() => { + resolver = new Resolver(dddKitPath); + }); + + it('should return empty array if UID not found', () => { + mockSafeGet.mockReturnValue(void 0); + + const result = resolver.getRequires('nonexistent'); + + expect(result).toEqual([]); + }); + + it('should return requires for UID', () => { + const entry = { path: 'path', status: 'active', requires: ['req1', 'req2'] }; + mockSafeGet.mockReturnValue(entry); + + const result = resolver.getRequires('uid'); + + expect(result).toEqual(['req1', 'req2']); + }); + }); + + describe('getAllUids', () => { + it('should return all UIDs from registry', () => { + const registry = { uid1: {}, uid2: {} }; + mockParseJsonFile.mockReturnValueOnce(registry); + resolver = new Resolver(dddKitPath); + + const result = resolver.getAllUids(); + + expect(result).toEqual(['uid1', 'uid2']); + }); + }); + + describe('getRegistry', () => { + it('should return registry with details', () => { + const registry = { + uid1: { path: 'path1', status: 'active', requires: ['req1'] }, + uid2: { path: 'path2', status: 'inactive', requires: [] }, + }; + mockParseJsonFile.mockReturnValueOnce(registry); + resolver = new Resolver(dddKitPath); + + const result = resolver.getRegistry(); + + expect(result).toEqual({ + uid1: { requires: ['req1'], status: 'active' }, + uid2: { requires: [], status: 'inactive' }, + }); + }); + }); + + describe('updateAlias', () => { + it('should update alias mapping', () => { + resolver = new Resolver(dddKitPath); + + resolver.updateAlias('oldUid', 'newUid'); + + expect(resolver['aliases']['oldUid']).toBe('newUid'); + }); + }); +}); diff --git a/src/core/helpers/uid-resolver.ts b/src/core/helpers/uid-resolver.ts index 7e7721f..ffcfacd 100644 --- a/src/core/helpers/uid-resolver.ts +++ b/src/core/helpers/uid-resolver.ts @@ -1,46 +1,47 @@ import * as path from 'path'; -import type { IResolver } from '../../types/repository'; -import { FileManager } from '../storage/file-manager'; +import { IRegistryEntry, IResolver, RegistryEntryDetails } from '../../types'; import { parseJsonFile } from '../parsers/json.parser'; +import { FileManager } from '../storage'; -import { isString, safeGet } from './type-guards'; - -interface IRegistryEntry { - path: string; - status: string; - sha: string; - aliases: string[]; - requires: string[]; -} - -interface RegistryEntryDetails { - status: string; - requires: string[]; -} +import { safeGet } from './object.helper'; +import { isString } from './type.helper'; export class Resolver implements IResolver { private registry: Record = {}; private aliases: Record = {}; private readonly dddKitPath: string; + private readonly fileManager: FileManager; + /** + * Creates a new Resolver instance. + * @param dddKitPath - The path to the DDD-Kit standards directory + */ constructor(dddKitPath: string) { this.dddKitPath = dddKitPath; + this.fileManager = new FileManager(); this.loadCatalogs(); } + /** + * Loads the registry and aliases catalogs from the file system. + */ private loadCatalogs() { - const fileManager = new FileManager(); const registryPath = path.join(this.dddKitPath, 'standards', 'catalogs', 'registry.json'); const aliasesPath = path.join(this.dddKitPath, 'standards', 'catalogs', 'aliases.json'); - if (fileManager.existsSync(registryPath)) { - this.registry = parseJsonFile(registryPath, fileManager) || {}; + if (this.fileManager.existsSync(registryPath)) { + this.registry = parseJsonFile(registryPath, this.fileManager) || {}; } - if (fileManager.existsSync(aliasesPath)) { - this.aliases = parseJsonFile(aliasesPath, fileManager) || {}; + if (this.fileManager.existsSync(aliasesPath)) { + this.aliases = parseJsonFile(aliasesPath, this.fileManager) || {}; } } + /** + * Resolves a UID to its content and metadata. + * @param uid - The UID to resolve + * @returns The resolved content and metadata, or null if not found + */ resolve(uid: string): { path: string; content: string; status: string } | null { const actualUidRaw = safeGet(this.aliases, uid); const actualUid = isString(actualUidRaw) ? actualUidRaw : uid; @@ -49,13 +50,18 @@ export class Resolver implements IResolver { return null; } const fullPath = path.join(this.dddKitPath, entry.path); - if (!FileManager.existsSync(fullPath)) { + if (!this.fileManager.existsSync(fullPath)) { return null; } - const content = FileManager.readFileSync(fullPath); + const content = this.fileManager.readFileSync(fullPath); return { content, path: entry.path, status: entry.status }; } + /** + * Gets the requirements (dependencies) for a UID. + * @param uid - The UID to get requirements for + * @returns Array of required UIDs + */ getRequires(uid: string): string[] { const entry = safeGet(this.registry, uid); if (!entry) { @@ -64,10 +70,18 @@ export class Resolver implements IResolver { return entry.requires; } + /** + * Gets all available UIDs in the registry. + * @returns Array of all UID strings + */ getAllUids(): string[] { return Object.keys(this.registry); } + /** + * Gets the complete registry with status and requirements for each UID. + * @returns Registry object with UID details + */ getRegistry(): Record { const result: Record = {}; for (const [uid, entry] of Object.entries(this.registry)) { @@ -79,6 +93,11 @@ export class Resolver implements IResolver { return result; } + /** + * Updates an alias mapping from old UID to new UID. + * @param oldUid - The old UID to be aliased + * @param newUid - The new UID to map to + */ updateAlias(oldUid: string, newUid: string): void { // eslint-disable-next-line security/detect-object-injection this.aliases[oldUid] = newUid; diff --git a/src/core/parsers/json.parser.test.ts b/src/core/parsers/json.parser.test.ts new file mode 100644 index 0000000..c47ca0e --- /dev/null +++ b/src/core/parsers/json.parser.test.ts @@ -0,0 +1,136 @@ +import { IFileManager, ILogger } from '../../types'; + +import { formatJson, parseJsonFile, writeJsonFile, safeJsonParse } from './json.parser'; + +describe('json.parser', () => { + let mockFileManager: jest.Mocked; + let mockLogger: jest.Mocked; + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + mockFileManager = { + existsSync: jest.fn(), + isReadable: jest.fn(), + mkdir: jest.fn(), + mkdirSync: jest.fn(), + readFile: jest.fn(), + readFileSync: jest.fn(), + statSync: jest.fn(), + writeFile: jest.fn(), + writeFileSync: jest.fn(), + } as unknown as jest.Mocked; + mockLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + child: jest.fn(), + } as unknown as jest.Mocked; + }); + + describe('formatJson', () => { + it('should format object as JSON string with default indent', () => { + const obj = { key: 'value' }; + const result = formatJson(obj); + expect(result).toBe('{"key":"value"}'); + }); + + it('should format object as JSON string with specified indent', () => { + const obj = { key: 'value' }; + const result = formatJson(obj, 2); + expect(result).toBe('{\n "key": "value"\n}'); + }); + }); + + describe('parseJsonFile', () => { + it('should parse valid JSON file and return object', () => { + const filePath = 'test.json'; + const content = '{"key":"value"}'; + mockFileManager.readFileSync.mockReturnValue(content); + const result = parseJsonFile(filePath, mockFileManager, mockLogger); + expect(result).toEqual({ key: 'value' }); + expect(mockLogger.debug).toHaveBeenCalledWith('Parsed JSON file', { filePath }); + }); + + it('should return null on invalid JSON and log error', () => { + const filePath = 'test.json'; + const content = 'invalid json'; + mockFileManager.readFileSync.mockReturnValue(content); + const result = parseJsonFile(filePath, mockFileManager, mockLogger); + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith('Failed to parse JSON file', { + error: expect.any(String), + filePath, + }); + }); + + it('should use default logger if none provided', () => { + const filePath = 'test.json'; + const content = '{"key":"value"}'; + mockFileManager.readFileSync.mockReturnValue(content); + const result = parseJsonFile(filePath, mockFileManager); + expect(result).toEqual({ key: 'value' }); + }); + }); + + const writeError = () => { + throw new Error('Write failed'); + }; + + describe('writeJsonFile', () => { + it('should write object to JSON file and return true', () => { + const filePath = 'test.json'; + const data = { key: 'value' }; + const result = writeJsonFile(filePath, data, mockFileManager, mockLogger); + expect(result).toBe(true); + expect(mockFileManager.writeFileSync).toHaveBeenCalledWith(filePath, '{"key":"value"}'); + expect(mockLogger.debug).toHaveBeenCalledWith('Wrote JSON file', { filePath }); + }); + + it('should return false on write error and log error', () => { + const filePath = 'test.json'; + const data = { key: 'value' }; + mockFileManager.writeFileSync.mockImplementation(writeError); + const result = writeJsonFile(filePath, data, mockFileManager, mockLogger); + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith('Failed to write JSON file', { + error: 'Error: Write failed', + filePath, + }); + }); + + it('should use default logger if none provided', () => { + const filePath = 'test.json'; + const data = { key: 'value' }; + const result = writeJsonFile(filePath, data, mockFileManager); + expect(result).toBe(true); + expect(mockFileManager.writeFileSync).toHaveBeenCalledWith(filePath, '{"key":"value"}'); + }); + }); + + describe('safeJsonParse', () => { + it('should parse valid JSON string and return object', () => { + const jsonString = '{"key":"value"}'; + const result = safeJsonParse(jsonString, mockLogger); + expect(result).toEqual({ key: 'value' }); + }); + + it('should return null on invalid JSON and log warning', () => { + const jsonString = 'invalid json'; + const result = safeJsonParse(jsonString, mockLogger); + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith('Failed to parse JSON string', { + error: expect.any(String), + }); + }); + + it('should use default logger if none provided', () => { + const jsonString = '{"key":"value"}'; + const result = safeJsonParse(jsonString); + expect(result).toEqual({ key: 'value' }); + }); + }); +}); diff --git a/src/core/parsers/json.parser.ts b/src/core/parsers/json.parser.ts index d2dbab1..62416ff 100644 --- a/src/core/parsers/json.parser.ts +++ b/src/core/parsers/json.parser.ts @@ -1,5 +1,4 @@ -import { ILogger } from '../../types/observability'; -import { IFileManager } from '../../types/core'; +import { IFileManager, ILogger } from '../../types'; import { getLogger } from '../system/logger'; /** diff --git a/src/core/parsers/yaml.parser.test.ts b/src/core/parsers/yaml.parser.test.ts new file mode 100644 index 0000000..0e7d956 --- /dev/null +++ b/src/core/parsers/yaml.parser.test.ts @@ -0,0 +1,218 @@ +import { dump, load } from 'js-yaml'; + +import { IFileManager, ILogger, UpdateYamlBlockOptions } from '../../types'; + +import { + addYamlBlockFromFile, + dumpYaml, + extractYamlBlocks, + parseYamlBlock, + parseYamlBlocksFromFile, + removeYamlBlockById, + updateYamlBlockById, +} from './yaml.parser'; + +// Mock dependencies +jest.mock('js-yaml', () => ({ + dump: jest.fn(), + load: jest.fn(), + JSON_SCHEMA: {}, +})); + +jest.mock('../system/logger', () => ({ + getLogger: jest.fn(() => ({ + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + })), +})); + +const throwInvalidYaml = (): never => { + throw new Error('Invalid YAML'); +}; + +describe('yaml.parser', () => { + let mockFileSystem: jest.Mocked; + let mockLogger: jest.Mocked; + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + mockFileSystem = { + existsSync: jest.fn(), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger = { + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + }); + + describe('extractYamlBlocks', () => { + it('should extract YAML blocks from markdown', () => { + const md = `--- +key: value +--- +Some text +--- +key2: value2 +---`; + const result = extractYamlBlocks(md); + expect(result).toEqual(['key: value', 'key2: value2']); + }); + + it('should return empty array if no blocks', () => { + const md = 'No YAML here'; + const result = extractYamlBlocks(md); + expect(result).toEqual([]); + }); + + it('should handle CRLF', () => { + const md = '---\r\nkey: value\r\n---'; + const result = extractYamlBlocks(md); + expect(result).toEqual(['key: value']); + }); + }); + + describe('parseYamlBlock', () => { + it('should parse valid YAML block', () => { + (load as jest.Mock).mockReturnValue({ key: 'value' }); + const result = parseYamlBlock('key: value', mockLogger); + expect(result).toEqual({ key: 'value' }); + expect(load).toHaveBeenCalledWith('key: value', { schema: {} }); + }); + + it('should return null for invalid YAML', () => { + (load as jest.Mock).mockImplementation(throwInvalidYaml); + const result = parseYamlBlock('invalid: yaml: :', mockLogger); + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should return null if not an object', () => { + (load as jest.Mock).mockReturnValue('string'); + const result = parseYamlBlock('string', mockLogger); + expect(result).toBeNull(); + }); + }); + + describe('dumpYaml', () => { + it('should dump object to YAML string', () => { + (dump as jest.Mock).mockReturnValue('key: value\n'); + const result = dumpYaml({ key: 'value' }); + expect(result).toBe('key: value\n'); + expect(dump).toHaveBeenCalledWith({ key: 'value' }); + }); + }); + + describe('addYamlBlockFromFile', () => { + it('should add YAML block from source to target', () => { + mockFileSystem.existsSync.mockReturnValue(true); + mockFileSystem.readFileSync + .mockReturnValueOnce('---\nkey: value\n---') + .mockReturnValueOnce('existing content\n'); + const result = addYamlBlockFromFile('source.md', 'target.md', mockFileSystem, mockLogger); + expect(result).toBe(true); + expect(mockFileSystem.writeFileSync).toHaveBeenCalledWith( + 'target.md', + 'existing content\n---\nkey: value\n---\n', + ); + expect(mockLogger.info).toHaveBeenCalled(); + }); + + it('should return false if source file does not exist', () => { + mockFileSystem.existsSync.mockReturnValue(false); + const result = addYamlBlockFromFile('source.md', 'target.md', mockFileSystem, mockLogger); + expect(result).toBe(false); + }); + + it('should return false if no YAML block in source', () => { + mockFileSystem.existsSync.mockReturnValue(true); + mockFileSystem.readFileSync.mockReturnValue('no yaml'); + const result = addYamlBlockFromFile('source.md', 'target.md', mockFileSystem, mockLogger); + expect(result).toBe(false); + }); + }); + + describe('parseYamlBlocksFromFile', () => { + it('should parse all YAML blocks from file', () => { + mockFileSystem.readFileSync.mockReturnValue('---\nkey: value\n---\n---\nkey2: value2\n---'); + (load as jest.Mock) + .mockReturnValueOnce({ key: 'value' }) + .mockReturnValueOnce({ key2: 'value2' }); + const result = parseYamlBlocksFromFile('file.md', mockFileSystem, mockLogger); + expect(result).toEqual([{ key: 'value' }, { key2: 'value2' }]); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + + it('should skip invalid blocks', () => { + mockFileSystem.readFileSync.mockReturnValue('---\nkey: value\n---\n---\ninvalid\n---'); + (load as jest.Mock).mockReturnValueOnce({ key: 'value' }).mockReturnValueOnce(null); + const result = parseYamlBlocksFromFile('file.md', mockFileSystem, mockLogger); + expect(result).toEqual([{ key: 'value' }]); + }); + }); + + describe('updateYamlBlockById', () => { + it('should update YAML block by ID', () => { + mockFileSystem.readFileSync.mockReturnValue( + '---\n---\nid: 1\nkey: old\n---\n---\nid: 2\n---', + ); + (load as jest.Mock).mockReturnValue({ id: '1', key: 'old' }); + (dump as jest.Mock).mockReturnValue('id: 1\nkey: new\n'); + const options: UpdateYamlBlockOptions = { + filePath: 'file.md', + id: '1', + updatedData: { key: 'new' }, + fileSystem: mockFileSystem, + logger: mockLogger, + }; + const result = updateYamlBlockById(options); + expect(result).toBe(true); + expect(mockFileSystem.writeFileSync).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalled(); + }); + + it('should return false if ID not found', () => { + mockFileSystem.readFileSync.mockReturnValue('---\n---\nid: 1\n---'); + const options: UpdateYamlBlockOptions = { + filePath: 'file.md', + id: '2', + updatedData: { key: 'new' }, + fileSystem: mockFileSystem, + logger: mockLogger, + }; + const result = updateYamlBlockById(options); + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('removeYamlBlockById', () => { + it('should remove YAML block by ID', () => { + mockFileSystem.readFileSync.mockReturnValue( + '---\n---\nid: 1\nkey: value\n---\n---\nid: 2\n---', + ); + (load as jest.Mock).mockReturnValue({ id: '1' }); + const result = removeYamlBlockById('file.md', mockFileSystem, '1', mockLogger); + expect(result).toBe(true); + expect(mockFileSystem.writeFileSync).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalled(); + }); + + it('should return false if ID not found', () => { + mockFileSystem.readFileSync.mockReturnValue('---\n---\nid: 1\n---'); + (load as jest.Mock).mockReturnValue({ id: '1' }); + const result = removeYamlBlockById('file.md', mockFileSystem, '2', mockLogger); + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/parsers/yaml.parser.ts b/src/core/parsers/yaml.parser.ts index a0c8a45..f271884 100644 --- a/src/core/parsers/yaml.parser.ts +++ b/src/core/parsers/yaml.parser.ts @@ -1,18 +1,17 @@ import * as path from 'path'; -import { load, dump, JSON_SCHEMA } from 'js-yaml'; +import { dump, JSON_SCHEMA, load } from 'js-yaml'; -import { ILogger } from '../../types/observability'; -import { IFileManager } from '../../types/core'; +import { IFileManager, ILogger, UpdateYamlBlockOptions } from '../../types'; +import { isNonEmptyString, isObject } from '../helpers/type.helper'; import { getLogger } from '../system/logger'; -import { isNonEmptyString, isObject } from '../helpers/type-guards'; /** * Extracts YAML blocks from markdown content. * @param md - The markdown content to parse. * @returns An array of YAML block contents. */ -function extractYamlBlocks(md: string): string[] { +export function extractYamlBlocks(md: string): string[] { const blocks: string[] = []; const pattern = /---\r?\n([\s\S]*?)\r?\n---/g; let match: RegExpExecArray | null; @@ -30,7 +29,7 @@ function extractYamlBlocks(md: string): string[] { * @param logger - Optional logger. * @returns The parsed object or null if failed. */ -function parseYamlBlock>( +export function parseYamlBlock>( block: string, logger?: ILogger, ): T | null { @@ -54,7 +53,7 @@ function parseYamlBlock>( * @param obj - The object to dump. * @returns The YAML string representation. */ -function dumpYaml(obj: Record): string { +export function dumpYaml(obj: Record): string { return dump(obj); } @@ -130,17 +129,6 @@ export function parseYamlBlocksFromFile( return out; } -/** - * Options for updating a YAML block by ID. - */ -interface UpdateYamlBlockOptions { - filePath: string; - id: string; - updatedData: Record; - fileSystem: IFileManager; - logger?: ILogger; -} - /** * Updates a YAML block in a markdown file by ID. * @param options - The options for updating the YAML block. @@ -151,20 +139,27 @@ export function updateYamlBlockById(options: UpdateYamlBlockOptions): boolean { const log = logger ?? getLogger(); try { const content = fileSystem.readFileSync(filePath); - const parts = content.split(/---\r?\n/); + const blockPattern = /---\r?\n(?!---\r?\n)([\s\S]*?)\r?\n---/g; + let match: RegExpExecArray | null; + let updatedContent = content; let found = false; - for (let i = 1; i < parts.length; i += 2) { - // eslint-disable-next-line security/detect-object-injection - const yamlContent = parts[i]; - if (isNonEmptyString(yamlContent)) { - const parsed = parseYamlBlock(yamlContent, log); - if (parsed && parsed['id'] === id) { - // eslint-disable-next-line security/detect-object-injection - parts[i] = dumpYaml({ ...parsed, ...updatedData }); - found = true; - break; - } + while ((match = blockPattern.exec(content)) !== null) { + const fullBlock = match[0]; + const yamlContent = match[1]; + + if (!isNonEmptyString(yamlContent)) { + continue; + } + + const parsed = parseYamlBlock(yamlContent, log); + if (parsed && parsed['id'] === id) { + const merged = { ...parsed, ...updatedData }; + const dumped = dumpYaml(merged).trimEnd(); + const replacement = `---\n${dumped}\n---`; + updatedContent = updatedContent.replace(fullBlock, replacement); + found = true; + break; } } @@ -173,8 +168,7 @@ export function updateYamlBlockById(options: UpdateYamlBlockOptions): boolean { return false; } - const newContent = parts.join('---\n'); - fileSystem.writeFileSync(filePath, newContent); + fileSystem.writeFileSync(filePath, updatedContent); log.info(`Updated YAML block with ID ${id}`); return true; } catch (e) { diff --git a/src/core/processing/exclusion.filter.ts b/src/core/processing/exclusion.filter.ts index 8f462d7..cfb96c1 100644 --- a/src/core/processing/exclusion.filter.ts +++ b/src/core/processing/exclusion.filter.ts @@ -1,6 +1,6 @@ import { ITask } from '../../types'; import { IExclusionFilter } from '../../types/repository'; -import { isNullOrUndefined } from '../helpers/type-guards'; +import { isNullOrUndefined } from '../helpers/type.helper'; /** * Handles exclusion pattern matching for tasks. diff --git a/src/core/processing/hydrate.ts b/src/core/processing/hydrate.ts index de5dbdf..186ecc3 100644 --- a/src/core/processing/hydrate.ts +++ b/src/core/processing/hydrate.ts @@ -1,17 +1,16 @@ import { createHash } from 'crypto'; +import { ILogger, TaskProviderType } from '../../types'; +import { UidResolutionError, UidStatusError } from '../../types/core'; +import { Resolver } from '../helpers/uid-resolver'; +import { Renderer } from '../rendering/renderer'; +import { TaskProviderFactory } from '../storage/task-provider.factory'; import type { ITask, IResolvedRef, IHydrationOptions, ITaskHydrationUseCase, } from '../../types/tasks'; -import { ILogger, TaskProviderType } from '../../types'; -import { UidStatusError } from '../../errors/uid-status.error'; -import { UidResolutionError } from '../../errors/uid-resolution.error'; -import { Resolver } from '../helpers/uid-resolver'; -import { Renderer } from '../rendering/renderer'; -import { TaskProviderFactory } from '../storage/task-provider.factory'; export class TaskHydrationService implements ITaskHydrationUseCase { constructor( diff --git a/src/core/processing/task.processor.ts b/src/core/processing/task.processor.ts index e4dab3a..a5ee25e 100644 --- a/src/core/processing/task.processor.ts +++ b/src/core/processing/task.processor.ts @@ -1,8 +1,8 @@ -import { ITaskFixer, IExclusionFilter, IValidationResultBuilder, ITask } from '../../types'; +import { IExclusionFilter, ITask, ITaskFixer, IValidationResultBuilder } from '../../types'; import { ValidationContext } from '../../validators/validation.context'; -import { isTask } from '../helpers/type-guards'; +import { isTask } from '../helpers/type.helper'; import { TaskPersistenceService } from '../services/task-persistence.service'; -import { TaskValidationService } from '../services/task-validation-processor.service'; +import { TaskValidationProcessorService } from '../services/task-validation-processor.service'; export class TaskProcessor { /** @@ -15,7 +15,7 @@ export class TaskProcessor { exclusionFilter: IExclusionFilter; resultBuilder: IValidationResultBuilder; context: ValidationContext; - validationService: TaskValidationService; + validationService: TaskValidationProcessorService; persistenceService: TaskPersistenceService; }, ) {} @@ -82,7 +82,11 @@ export class TaskProcessor { * ``` */ private async applyFixes(taskObj: ITask, taskId: string, index: number): Promise { - const localFixes = this.options.fixer.applyBasicFixes(taskObj); + const result = this.options.fixer.applyBasicFixes(taskObj); + const { fixedTask, fixes: localFixes } = result; + + // Update the task object with the fixed version + Object.assign(taskObj, fixedTask); if (localFixes.length > 0) { this.options.resultBuilder.addFixes(localFixes); diff --git a/src/core/rendering/console-output.writer.ts b/src/core/rendering/console-output.writer.ts new file mode 100644 index 0000000..9e6df9b --- /dev/null +++ b/src/core/rendering/console-output.writer.ts @@ -0,0 +1,144 @@ +import chalk from 'chalk'; + +import { IOutputWriter, OutputFormat } from '../../types/rendering'; +import { isNullOrUndefined, isObject } from '../helpers/type.helper'; +import { formatJson } from '../parsers/json.parser'; + +/** + * Console-based implementation of IOutputWriter. + * + * Provides colored output, structured formatting, and different message types + * for CLI applications. + */ +export class ConsoleOutputWriter implements IOutputWriter { + /** + * Writes a success message with green coloring. + */ + success(message: string): void { + console.log(chalk.green(message)); + } + + /** + * Writes an informational message. + */ + info(message: string): void { + console.log(message); + } + + /** + * Writes a warning message with yellow coloring. + */ + warning(message: string): void { + console.log(chalk.yellow(message)); + } + + /** + * Writes an error message with red coloring. + */ + error(message: string): void { + console.log(chalk.red(message)); + } + + /** + * Writes a plain message without any formatting. + */ + write(message: string): void { + console.log(message); + } + + /** + * Writes a line break. + */ + newline(): void { + console.log(); + } + + /** + * Writes structured data in the specified format. + */ + writeFormatted(data: unknown, format: OutputFormat): void { + switch (format) { + case OutputFormat.JSON: + console.log(formatJson(data)); + break; + case OutputFormat.CSV: + this.writeCsv(data); + break; + case OutputFormat.TABLE: + this.writeTable(data); + break; + default: + console.log(String(data)); + } + } + + /** + * Writes a section header with bold formatting. + */ + section(title: string): void { + console.log(chalk.bold(title)); + } + + /** + * Writes a key-value pair with aligned formatting. + */ + keyValue(key: string, value: string): void { + console.log(`${key}: ${value}`); + } + + /** + * Writes data in CSV format. + */ + private writeCsv(data: unknown): void { + if (Array.isArray(data)) { + this.writeCsvArray(data); + } else { + console.log(String(data)); + } + } + + /** + * Writes an array of objects in CSV format. + */ + private writeCsvArray(data: unknown[]): void { + if (data.length === 0) return; + + // Get headers from first object + const firstItem = data[0]; + if (!isObject(firstItem) || isNullOrUndefined(firstItem)) { + data.forEach((item) => { + console.log(String(item)); + }); + return; + } + + const headers = Object.keys(firstItem); + console.log(headers.join(',')); + + // Write data rows + data.forEach((item) => { + if (isObject(item)) { + const values = headers.map((header) => { + const value = Reflect.get(item, header) ?? null; + // Escape quotes and wrap in quotes if contains comma + const stringValue = String(value ?? ''); + return stringValue.includes(',') ? `"${stringValue.replace(/"/g, '""')}"` : stringValue; + }); + console.log(values.join(',')); + } + }); + } + + /** + * Writes data in table format (simplified). + */ + private writeTable(data: unknown): void { + if (Array.isArray(data)) { + data.forEach((item) => { + console.log(`- ${String(item)}`); + }); + } else { + console.log(String(data)); + } + } +} diff --git a/src/core/rendering/index.ts b/src/core/rendering/index.ts new file mode 100644 index 0000000..c843e83 --- /dev/null +++ b/src/core/rendering/index.ts @@ -0,0 +1 @@ +export { ConsoleOutputWriter } from './console-output.writer'; diff --git a/src/core/rendering/renderer.ts b/src/core/rendering/renderer.ts index 33934d3..b052ac8 100644 --- a/src/core/rendering/renderer.ts +++ b/src/core/rendering/renderer.ts @@ -1,8 +1,8 @@ import * as path from 'path'; import { IRenderer, IResolvedRef } from '../../types'; +import { isNonEmptyString } from '../helpers/type.helper'; import { FileManager } from '../storage/file-manager'; -import { isNullOrUndefined, isNonEmptyString } from '../helpers/type-guards'; export class Renderer implements IRenderer { private readonly targetPath: string; @@ -11,6 +11,13 @@ export class Renderer implements IRenderer { this.targetPath = targetPath; } + /** + * Renders resolved references into the target repository. + * Creates/updates files under .development/feature-/implementation-notes.md + * @param taskId The ID of the task being processed. + * @param resolvedRefs The array of resolved references to render. + * @param provenance Information about the source of the references. + */ render( taskId: string, resolvedRefs: IResolvedRef[], @@ -48,23 +55,69 @@ ${this.extractSection(ref.content, ref.section)} FileManager.writeFileSync(notesPath, content); } + /** + * Extracts the relevant section from the content if specified. + * If no section is specified, returns the full content without front-matter. + * @param content The full markdown content. + * @param section Optional section to extract. + * @returns The extracted section or full content without front-matter. + */ private extractSection(content: string, section?: string): string { - // Strip front-matter - const stripped = content.replace(/^---\n[\s\S]*?\n---\n/, ''); - if (isNullOrUndefined(section)) return stripped; - // Simple extraction, assume ## section - const lines = stripped.split('\n'); - const start = lines.findIndex((l) => l.startsWith(`## ${section}`)); - if (start === -1) return stripped; - let end = lines.length; - for (let i = start + 1; i < lines.length; i++) { + const strippedContent = this.stripFrontMatter(content); + if (section == null) return strippedContent; + + return this.extractSpecificSection(strippedContent, section); + } + + /** + * Strips front-matter from markdown content. + * @param content The markdown content. + * @returns Content without front-matter. + */ + private stripFrontMatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n/, ''); + } + + /** + * Extracts a specific section from content. + * @param content The markdown content. + * @param section The section to extract (e.g., "Implementation Notes"). + * @returns The content of the specified section or the full content if not found. + */ + private extractSpecificSection(content: string, section: string): string { + const lines = content.split('\n'); + const sectionStart = this.findSectionStart(lines, section); + + if (sectionStart === -1) return content; + + const sectionEnd = this.findSectionEnd(lines, sectionStart); + return lines.slice(sectionStart, sectionEnd).join('\n'); + } + + /** + * Finds the starting line index of a section. + * @param lines The lines of the content. + * @param section The section to find (e.g., "Implementation Notes"). + * @returns The index of the section start or -1 if not found. + */ + private findSectionStart(lines: string[], section: string): number { + return lines.findIndex((line) => line.startsWith(`## ${section}`)); + } + + /** + * Finds the ending line index of a section. + * @param lines The lines of the content. + * @param startIndex The starting index of the section. + * @returns The index of the section end or the length of lines if not found. + */ + private findSectionEnd(lines: string[], startIndex: number): number { + for (let i = startIndex + 1; i < lines.length; i++) { // eslint-disable-next-line security/detect-object-injection const line = lines[i]; if (isNonEmptyString(line) && line.startsWith('## ')) { - end = i; - break; + return i; } } - return lines.slice(start, end).join('\n'); + return lines.length; } } diff --git a/src/core/rendering/validation-result.renderer.ts b/src/core/rendering/validation-result.renderer.ts index f662155..b79639f 100644 --- a/src/core/rendering/validation-result.renderer.ts +++ b/src/core/rendering/validation-result.renderer.ts @@ -1,21 +1,23 @@ -import chalk from 'chalk'; - import { - ILogger, - ValidateFixCommandOptions, FixRecord, - OutputFormat, + ILogger, IValidationResult, + OutputFormat, + ValidateFixCommandOptions, } from '../../types'; -import { formatJson } from '../parsers/json.parser'; -import { isEmptyArray } from '../helpers/type-guards'; +import { IOutputWriter } from '../../types/rendering'; + +import { ConsoleOutputWriter } from './console-output.writer'; /** * Handles rendering of validation results in different output formats. * Responsible for formatting and displaying validation results, fixes, and completion messages. */ export class ValidationResultRenderer { - constructor(private readonly logger: ILogger) {} + constructor( + private readonly logger: ILogger, + private readonly outputWriter: IOutputWriter = new ConsoleOutputWriter(), + ) {} /** * Renders validation results based on the specified format. @@ -24,18 +26,18 @@ export class ValidationResultRenderer { // Early return for successful validation with no fixes if (result.isValid && (result.fixesApplied ?? 0) === 0) { const count = taskCount ?? this.getTaskCount(); - console.log(chalk.green(`All ${count} tasks validate against schema`)); + this.outputWriter.success(`All ${count} tasks validate against schema`); this.logger.info('All tasks validated successfully', { taskCount: count }); return; } // Output fixes if any exist - if (result.fixes && !isEmptyArray(result.fixes)) { + if (result.fixes && result.fixes.length > 0) { this.renderFixes(options, result.fixes, result.fixesApplied, options.dryRun); } // Output completion message for successful fixes - if (!result.errors || isEmptyArray(result.errors)) { + if (!result.errors || result.errors.length === 0) { this.renderCompletionMessage(result.fixes, result.fixesApplied, options.dryRun); } } @@ -68,7 +70,7 @@ export class ValidationResultRenderer { */ private renderJsonSummary(fixes: FixRecord[], errors: string[]): void { const summary = { errors, fixes }; - console.log(formatJson(summary)); + this.outputWriter.writeFormatted(summary, OutputFormat.JSON); this.logger.info('JSON summary generated', { errorCount: errors.length, fixCount: fixes.length, @@ -79,10 +81,7 @@ export class ValidationResultRenderer { * Renders validation results in CSV format. */ private renderCsvSummary(fixes: FixRecord[]): void { - console.log('id,field,old,new'); - for (const f of fixes) { - console.log(`"${f.id}","${f.field}","${String(f.old ?? '')}","${String(f.new)}"`); - } + this.outputWriter.writeFormatted(fixes, OutputFormat.CSV); this.logger.info('CSV summary generated', { fixCount: fixes.length }); } @@ -95,12 +94,12 @@ export class ValidationResultRenderer { isDryRun: boolean | undefined, ): void { if (isDryRun === true) { - console.log(chalk.yellow(`Planned ${fixes.length} fixes (dry-run):`)); - for (const m of fixes) console.log(`- ${m.id}: ${m.field} -> ${m.new}`); + this.outputWriter.warning(`Planned ${fixes.length} fixes (dry-run):`); + for (const m of fixes) this.outputWriter.write(`- ${m.id}: ${m.field} -> ${m.new}`); this.logger.info('Dry-run fixes displayed', { plannedFixes: fixes.length }); } else { - console.log(chalk.yellow(`Applied ${fixesApplied ?? 0} fixes:`)); - for (const m of fixes) console.log(`- ${m.id}: ${m.field} -> ${m.new}`); + this.outputWriter.warning(`Applied ${fixesApplied ?? 0} fixes:`); + for (const m of fixes) this.outputWriter.write(`- ${m.id}: ${m.field} -> ${m.new}`); this.logger.info('Applied fixes displayed', { appliedFixes: fixesApplied ?? 0 }); } } @@ -115,11 +114,11 @@ export class ValidationResultRenderer { ): void { if (isDryRun === true) { const plannedFixes = fixes?.length ?? 0; - console.log(chalk.green(`Dry-run complete; ${plannedFixes} fixes would have been applied.`)); + this.outputWriter.success(`Dry-run complete; ${plannedFixes} fixes would have been applied.`); this.logger.info('Dry-run completed', { plannedFixes }); } else { const appliedFixes = fixesApplied ?? 0; - console.log(chalk.green(`Validation and fixes completed; ${appliedFixes} changes written.`)); + this.outputWriter.success(`Validation and fixes completed; ${appliedFixes} changes written.`); this.logger.info('Validation and fixes completed', { appliedFixes }); } } diff --git a/src/core/services/task-persistence.service.ts b/src/core/services/task-persistence.service.ts index 930cbf9..1f8012c 100644 --- a/src/core/services/task-persistence.service.ts +++ b/src/core/services/task-persistence.service.ts @@ -1,5 +1,4 @@ -import { ITaskStore, ITask } from '../../types/tasks'; -import { ILogger } from '../../types/observability'; +import { ITaskStore, ILogger, ITask } from '../../types'; /** * Service responsible for persisting task changes. diff --git a/src/core/services/task-validation-processor.service.ts b/src/core/services/task-validation-processor.service.ts index 276ae7b..94a7d66 100644 --- a/src/core/services/task-validation-processor.service.ts +++ b/src/core/services/task-validation-processor.service.ts @@ -1,11 +1,10 @@ -import { ITask, ITaskValidator } from '../../types/tasks'; -import { IValidationResult, IValidationResultBuilder } from '../../types/validation'; +import { ITaskValidator, IValidationResultBuilder, ITask, IValidationResult } from '../../types'; /** * Service responsible for task validation operations. * Follows Single Responsibility Principle (SRP). */ -export class TaskValidationService { +export class TaskValidationProcessorService { constructor( private readonly validator: ITaskValidator, private readonly resultBuilder: IValidationResultBuilder, diff --git a/src/core/storage/default-task.store.ts b/src/core/storage/default-task.store.ts index f1586ad..ad0695d 100644 --- a/src/core/storage/default-task.store.ts +++ b/src/core/storage/default-task.store.ts @@ -1,4 +1,4 @@ -import { ITaskStore, ITask } from '../../types'; +import { ITask, ITaskStore } from '../../types'; import { TaskManager } from './task.manager'; @@ -14,6 +14,7 @@ export class DefaultTaskStore implements ITaskStore { /** * Lists all tasks from the TODO.md file. + * @returns An array of tasks. */ listTasks(): ITask[] { return this.todoManager.listTasks(); @@ -21,6 +22,8 @@ export class DefaultTaskStore implements ITaskStore { /** * Finds a task by its ID from the TODO.md file. + * @param id The ID of the task to find. + * @returns The task if found, otherwise null. */ findTaskById(id: string): ITask | null { return this.todoManager.findTaskById(id); @@ -28,6 +31,8 @@ export class DefaultTaskStore implements ITaskStore { /** * Adds a task from a file to the TODO.md file. + * @param filePath The path to the file containing the task. + * @returns True if the task was added successfully, otherwise false. */ addTaskFromFile(filePath: string): boolean { return this.todoManager.addTaskFromFile(filePath); @@ -35,6 +40,9 @@ export class DefaultTaskStore implements ITaskStore { /** * Updates a task by its ID using the TodoManager. + * @param id The ID of the task to update. + * @param task The updated task data. + * @returns True if the task was updated successfully, otherwise false. */ updateTaskById(id: string, task: ITask): boolean { return this.todoManager.updateTaskById(id, task); @@ -42,6 +50,8 @@ export class DefaultTaskStore implements ITaskStore { /** * Removes a task by ID from the TODO.md file. + * @param id The ID of the task to remove. + * @returns True if the task was removed successfully, otherwise false. */ removeTaskById(id: string): boolean { return this.todoManager.removeTaskById(id); @@ -49,6 +59,8 @@ export class DefaultTaskStore implements ITaskStore { /** * Previews the completion of a task without actually performing the action. + * @param id The ID of the task to preview completion for. + * @returns A string preview of the completion action. */ previewComplete(id: string): string { return this.todoManager.previewComplete(id); diff --git a/src/core/storage/file-manager.ts b/src/core/storage/file-manager.ts index 31a53b2..7c97d31 100644 --- a/src/core/storage/file-manager.ts +++ b/src/core/storage/file-manager.ts @@ -3,31 +3,61 @@ import * as fs from 'fs'; import { IFileManager } from '../../types'; export class FileManager implements IFileManager { + /** + * Synchronous file read + * @param path The file path to read. + * @returns The file content as a string. + */ static readFileSync(path: string): string { // eslint-disable-next-line security/detect-non-literal-fs-filename return fs.readFileSync(path, 'utf8'); } + /** + * Synchronous file write + * @param path The file path to write. + * @param content The content to write to the file. + */ static writeFileSync(path: string, content: string): void { // eslint-disable-next-line security/detect-non-literal-fs-filename fs.writeFileSync(path, content); } + /** + * Synchronous existence check + * @param path The file or directory path to check. + * @returns True if the path exists, false otherwise. + */ static existsSync(path: string): boolean { // eslint-disable-next-line security/detect-non-literal-fs-filename return fs.existsSync(path); } + /** + * Synchronous directory creation + * @param path The directory path to create. + * @param options Optional options, e.g. { recursive: true }. + */ static mkdirSync(path: string, options?: { recursive?: boolean }): void { // eslint-disable-next-line security/detect-non-literal-fs-filename fs.mkdirSync(path, options); } + /** + * Synchronous stat retrieval + * @param path The file or directory path to stat. + * @returns The fs.Stats object with isFile() and isDirectory() methods. + */ static statSync(path: string): { isFile(): boolean; isDirectory(): boolean } { // eslint-disable-next-line security/detect-non-literal-fs-filename return fs.statSync(path); } + /** + * Check if a file or directory is readable + * @param path The file or directory path to check. + * @returns True if the path is readable, false otherwise. + */ static isReadable(path: string): boolean { try { // eslint-disable-next-line security/detect-non-literal-fs-filename @@ -38,32 +68,65 @@ export class FileManager implements IFileManager { } } - // Instance wrappers delegating to static implementations (satisfy IFileManager) + /** + * Instance wrappers delegating to static implementations (satisfy IFileManager) + * @param path The file path to read. + * @returns The file content as a string. + */ readFileSync(path: string): string { return FileManager.readFileSync(path); } + /** + * Instance wrappers delegating to static implementations (satisfy IFileManager) + * @param path the file path to write. + * @param content the content to write. + */ writeFileSync(path: string, content: string): void { FileManager.writeFileSync(path, content); } - existsSync(path: string): boolean { - return FileManager.existsSync(path); - } - + /** + * Instance wrapper delegating to static implementation (satisfy IFileManager) + * @param path The directory path to create. + * @param options Optional options, e.g. { recursive: true }. + */ mkdirSync(path: string, options?: { recursive?: boolean }): void { FileManager.mkdirSync(path, options); } + /** + * Instance wrapper delegating to static implementation (satisfy IFileManager) + * @param path The file or directory path to stat. + * @returns The fs.Stats object with isFile() and isDirectory() methods. + */ statSync(path: string): { isFile(): boolean; isDirectory(): boolean } { return FileManager.statSync(path); } + /** + * Check if a file or directory is readable + * @param path The file or directory path to check. + * @returns True if the path is readable, false otherwise. + */ isReadable(path: string): boolean { return FileManager.isReadable(path); } - // Async implementations + /** + * Instance wrapper delegating to static implementation (satisfy IFileManager) + * @param path The file or directory path to check. + * @returns True if the path exists, false otherwise. + */ + existsSync(path: string): boolean { + return FileManager.existsSync(path); + } + + /** + * Asynchronous file read + * @param path The file path to read. + * @returns Promise that resolves to the file content as a string. + */ readFile(path: string): Promise { return new Promise((resolve, reject) => { // eslint-disable-next-line security/detect-non-literal-fs-filename @@ -77,6 +140,12 @@ export class FileManager implements IFileManager { }); } + /** + * Asynchronous file write + * @param path The file path to write. + * @param content The content to write to the file. + * @returns Promise that resolves when the write operation is complete. + */ writeFile(path: string, content: string): Promise { return new Promise((resolve, reject) => { // eslint-disable-next-line security/detect-non-literal-fs-filename @@ -90,6 +159,12 @@ export class FileManager implements IFileManager { }); } + /** + * Asynchronous directory creation + * @param path The directory path to create. + * @param options Optional options, e.g. { recursive: true }. + * @returns Promise that resolves when the directory creation is complete. + */ mkdir(path: string, options?: { recursive?: boolean }): Promise { return new Promise((resolve, reject) => { // eslint-disable-next-line security/detect-non-literal-fs-filename diff --git a/src/core/storage/github.types.ts b/src/core/storage/github.types.ts deleted file mode 100644 index dd52e19..0000000 --- a/src/core/storage/github.types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isObject } from '../helpers/type-guards'; - -/** - * GitHub API types for type safety - */ -interface GitHubUser { - login: string; - id: number; - avatar_url: string; -} - -export interface GitHubLabel { - id: number; - name: string; - color: string; - description: string | null; -} - -interface GitHubMilestone { - id: number; - title: string; - due_on: string | null; - state: 'open' | 'closed'; -} - -interface GitHubIssue { - id: number; - number: number; - title: string; - body: string | null; - state: 'open' | 'closed'; - created_at: string; - updated_at: string; - assignee: GitHubUser | null; - assignees: GitHubUser[]; - labels: GitHubLabel[]; - milestone: GitHubMilestone | null; - pull_request?: { - url: string; - html_url: string; - }; -} - -/** - * Type guard to check if an unknown value is a GitHub issue - */ -export function isGitHubIssue(value: unknown): value is GitHubIssue { - return ( - isObject(value) && - 'number' in value && - 'title' in value && - 'state' in value && - 'created_at' in value && - 'updated_at' in value - ); -} diff --git a/src/core/storage/issues.provider.ts b/src/core/storage/issues.provider.ts index 3bca39b..6e64338 100644 --- a/src/core/storage/issues.provider.ts +++ b/src/core/storage/issues.provider.ts @@ -1,10 +1,15 @@ -import { ITask, TaskState, TaskStatus } from '../../types/tasks'; -import { ITaskRepository } from '../../types/repository'; -import { ILogger } from '../../types/observability'; +import { + GitHubLabel, + ILogger, + isGitHubIssue, + ITask, + ITaskRepository, + TaskState, + TaskStatus, +} from '../../types'; +import { isNonEmptyString, isNullOrUndefined, isString } from '../helpers/type.helper'; import { formatJson } from '../parsers/json.parser'; -import { GitHubLabel, isGitHubIssue } from './github.types'; - /** * GitHub Issues provider for task management. * @@ -188,7 +193,7 @@ export class IssuesProvider implements ITaskRepository { ...(options.headers as Record), }; - if (typeof options.body === 'string') { + if (isString(options.body)) { headers['Content-Type'] = 'application/json'; } @@ -213,7 +218,7 @@ export class IssuesProvider implements ITaskRepository { repo: this.githubRepo, resolvedReferences: [], state: this.mapIssueStateToTaskState(issue.state), - status: issue.state === 'closed' ? TaskStatus.Closed : TaskStatus.Open, + status: issue.state === TaskStatus.Closed ? TaskStatus.Closed : TaskStatus.Open, title: issue.title, updated: issue.updated_at, }; @@ -230,22 +235,22 @@ export class IssuesProvider implements ITaskRepository { } private mapIssueStateToTaskState(issueState: string): TaskState { - return issueState === 'closed' ? TaskState.Completed : TaskState.Pending; + return issueState === TaskState.Completed ? TaskState.Completed : TaskState.Pending; } - private mapTaskStateToIssueState(taskState?: TaskState): 'open' | 'closed' { + private mapTaskStateToIssueState(taskState?: TaskState): TaskStatus.Open | TaskStatus.Closed { return taskState === TaskState.Completed || taskState === TaskState.Cancelled - ? 'closed' - : 'open'; + ? TaskStatus.Closed + : TaskStatus.Open; } private formatTaskBody(task: ITask): string { const parts: string[] = [`# ${task.title ?? task.id}\n`]; - if (typeof task.owner === 'string' && task.owner !== '' && task.owner !== 'Unassigned') { + if (isNonEmptyString(task.owner) && task.owner !== 'Unassigned') { parts.push(`**Assignee:** @${task.owner}`); } - if (typeof task.due === 'string' && task.due !== '') parts.push(`**Due Date:** ${task.due}`); - if (task.state != null) parts.push(`**State:** ${task.state}`); + if (isNonEmptyString(task.due)) parts.push(`**Due Date:** ${task.due}`); + if (!isNullOrUndefined(task.state)) parts.push(`**State:** ${task.state}`); if (Array.isArray(task.references) && task.references.length > 0) { parts.push(`\n**References:**\n${task.references.map((ref) => `- ${ref}`).join('\n')}`); } @@ -254,7 +259,7 @@ export class IssuesProvider implements ITaskRepository { private extractLabels(task: ITask): string[] { const labels: string[] = []; - if (typeof task.language === 'string' && task.language !== '') { + if (isNonEmptyString(task.language)) { labels.push(`lang:${task.language}`); } if (task.state != null) labels.push(`state:${task.state}`); diff --git a/src/core/storage/projects.provider.ts b/src/core/storage/projects.provider.ts index bfe6b33..57c4104 100644 --- a/src/core/storage/projects.provider.ts +++ b/src/core/storage/projects.provider.ts @@ -1,10 +1,17 @@ -import { ITask, TaskState, TaskStatus } from '../../types/tasks'; -import { ITaskRepository } from '../../types/repository'; -import { ILogger } from '../../types/observability'; +import { + GitHubProjectIssue, + GraphQLResponse, + hasContent, + ILogger, + ITask, + ITaskRepository, + ProjectV2Item, + TaskState, + TaskStatus, +} from '../../types'; +import { isNonEmptyString } from '../helpers/type.helper'; import { formatJson } from '../parsers/json.parser'; -import { ProjectV2Item, GraphQLResponse, GitHubProjectIssue, hasContent } from './projects.types'; - /** * GitHub Projects provider for task management. * Integrates with GitHub Projects (v2) API using GraphQL. @@ -164,9 +171,7 @@ export class ProjectsProvider implements ITaskRepository { return { branch: `feature/project-item-${content.number}`, created: content.createdAt, - ...(typeof content.milestone?.dueOn === 'string' && content.milestone.dueOn !== '' - ? { due: content.milestone.dueOn } - : {}), + ...(isNonEmptyString(content.milestone?.dueOn) ? { due: content.milestone.dueOn } : {}), id: item.id, issueNumber: content.number, owner: (() => { @@ -192,7 +197,7 @@ export class ProjectsProvider implements ITaskRepository { if (item.fieldValues?.nodes) { for (const fieldValue of item.fieldValues.nodes) { - if (typeof fieldValue.field?.name === 'string' && fieldValue.field.name !== '') { + if (isNonEmptyString(fieldValue.field?.name)) { fieldValues[fieldValue.field.name] = fieldValue.text ?? fieldValue.name ?? ''; } } diff --git a/src/core/storage/task-provider.factory.ts b/src/core/storage/task-provider.factory.ts index caa5abc..383ba45 100644 --- a/src/core/storage/task-provider.factory.ts +++ b/src/core/storage/task-provider.factory.ts @@ -1,4 +1,4 @@ -import { TaskProviderType, ILogger, ITaskRepository } from '../../types'; +import { ILogger, ITaskRepository, TaskProviderType } from '../../types'; import { IssuesProvider } from './issues.provider'; import { ProjectsProvider } from './projects.provider'; diff --git a/src/core/storage/task.manager.ts b/src/core/storage/task.manager.ts index de432b4..5d06bd7 100644 --- a/src/core/storage/task.manager.ts +++ b/src/core/storage/task.manager.ts @@ -1,14 +1,14 @@ import * as path from 'path'; -import { ITaskStore, IChangelogStore, ILogger, ITask } from '../../types'; +import { IChangelogStore, ILogger, ITask, ITaskStore } from '../../types'; +import { isNullOrUndefined } from '../helpers/type.helper'; import { - parseYamlBlocksFromFile, addYamlBlockFromFile, - updateYamlBlockById, + parseYamlBlocksFromFile, removeYamlBlockById, + updateYamlBlockById, } from '../parsers/yaml.parser'; import { getLogger } from '../system/logger'; -import { isNullOrUndefined } from '../helpers/type-guards'; import { FileManager } from './file-manager'; @@ -34,6 +34,7 @@ export class TaskManager implements ITaskStore, IChangelogStore { /** * Lists all tasks from the TODO.md file. + * @returns An array of tasks. */ listTasks(): ITask[] { const tasks = parseYamlBlocksFromFile(TODO_PATH, this.fileManager, this.logger) as ITask[]; @@ -43,6 +44,8 @@ export class TaskManager implements ITaskStore, IChangelogStore { /** * Finds a task by its ID from the TODO.md file. + * @param id The ID of the task to find. + * @returns The task if found, otherwise null. */ findTaskById(id: string): ITask | null { const tasks = this.listTasks(); @@ -53,6 +56,8 @@ export class TaskManager implements ITaskStore, IChangelogStore { /** * Adds a task from a file to the TODO.md file. + * @param filePath The path to the file containing the task in YAML format. + * @returns True if the task was added successfully, false otherwise. */ addTaskFromFile(filePath: string): boolean { return addYamlBlockFromFile(filePath, TODO_PATH, this.fileManager, this.logger); @@ -60,6 +65,9 @@ export class TaskManager implements ITaskStore, IChangelogStore { /** * Updates a task by ID in the TODO.md file. + * @param id The ID of the task to update. + * @param updatedTask The updated task data. + * @returns True if the task was updated successfully, false otherwise. */ updateTaskById(id: string, updatedTask: ITask): boolean { return updateYamlBlockById({ @@ -73,6 +81,8 @@ export class TaskManager implements ITaskStore, IChangelogStore { /** * Removes a task by ID from the TODO.md file. + * @param id The ID of the task to remove. + * @returns True if the task was removed successfully, false otherwise. */ removeTaskById(id: string): boolean { return removeYamlBlockById(TODO_PATH, this.fileManager, id, this.logger); @@ -80,33 +90,21 @@ export class TaskManager implements ITaskStore, IChangelogStore { /** * Appends an entry to the CHANGELOG.md file under the "Unreleased" section. + * @param entry The changelog entry to add. */ appendToChangelog(entry: string): void { if (!this.fileManager.existsSync(CHANGELOG_PATH)) { - this.fileManager.writeFileSync(CHANGELOG_PATH, `# Changelog\n\nUnreleased\n\n${entry}\n`); - this.logger.info('Created CHANGELOG.md and appended entry', { entry }); - return; - } - - const content = this.fileManager.readFileSync(CHANGELOG_PATH); - const idx = content.indexOf('Unreleased'); - if (idx === -1) { - // append at top - const newContent = `# Changelog\n\nUnreleased\n\n${entry}\n\n${content}`; - this.fileManager.writeFileSync(CHANGELOG_PATH, newContent); + this.createInitialChangelog(entry); return; } - // find end of line after Unreleased heading - const after = content.indexOf('\n', idx); - const insertPos = after + 1; - const newContent = `${content.slice(0, insertPos)}- ${entry}\n${content.slice(insertPos)}`; - this.fileManager.writeFileSync(CHANGELOG_PATH, newContent); - this.logger.info('Appended entry to CHANGELOG.md', { entry }); + this.appendToExistingChangelog(entry); } /** * Previews the completion of a task without actually performing the action. + * @param id The ID of the task to preview completion for. + * @returns A string describing the actions that would be taken. */ previewComplete(id: string): string { const task = this.findTaskById(id); @@ -120,4 +118,66 @@ export class TaskManager implements ITaskStore, IChangelogStore { ); return lines.join('\n'); } + + /** + * Creates the initial CHANGELOG.md file with the first entry. + * @param entry The changelog entry to add. + */ + private createInitialChangelog(entry: string): void { + const content = `# Changelog\n\nUnreleased\n\n${entry}\n`; + this.fileManager.writeFileSync(CHANGELOG_PATH, content); + this.logger.info('Created CHANGELOG.md and appended entry', { entry }); + } + + /** + * Appends an entry to an existing CHANGELOG.md file. + * @param entry The changelog entry to add. + */ + private appendToExistingChangelog(entry: string): void { + const content = this.fileManager.readFileSync(CHANGELOG_PATH); + const unreleasedIndex = content.indexOf('Unreleased'); + + if (unreleasedIndex === -1) { + this.appendAtTopOfChangelog(content, entry); + return; + } + + this.appendUnderUnreleasedSection(content, unreleasedIndex, entry); + } + + /** + * Appends entry at the top of changelog when no "Unreleased" section exists. + * @param content The existing changelog content. + * @param entry The changelog entry to add. + */ + private appendAtTopOfChangelog(content: string, entry: string): void { + const newContent = `# Changelog\n\nUnreleased\n\n${entry}\n\n${content}`; + this.fileManager.writeFileSync(CHANGELOG_PATH, newContent); + } + + /** + * Appends entry under the existing "Unreleased" section. + * @param content The existing changelog content. + * @param unreleasedIndex The index of the "Unreleased" section. + * @param entry The changelog entry to add. + */ + private appendUnderUnreleasedSection( + content: string, + unreleasedIndex: number, + entry: string, + ): void { + const afterUnreleasedIndex = content.indexOf('\n', unreleasedIndex); + if (afterUnreleasedIndex === -1) { + this.logger.warn( + 'Could not append entry to CHANGELOG.md: no newline found after "Unreleased" section.', + { entry }, + ); + return; + } + + const insertPos = afterUnreleasedIndex + 1; + const newContent = `${content.slice(0, insertPos)}- ${entry}\n${content.slice(insertPos)}`; + this.fileManager.writeFileSync(CHANGELOG_PATH, newContent); + this.logger.info('Appended entry to CHANGELOG.md', { entry }); + } } diff --git a/src/core/storage/task.provider.ts b/src/core/storage/task.provider.ts index 756517e..f5192a7 100644 --- a/src/core/storage/task.provider.ts +++ b/src/core/storage/task.provider.ts @@ -1,7 +1,5 @@ -import { ITask } from '../../types/tasks'; -import { ITaskRepository } from '../../types/repository'; -import { ILogger } from '../../types/observability'; -import { isNullOrUndefined } from '../helpers/type-guards'; +import { ILogger, ITask, ITaskRepository, TaskState } from '../../types'; +import { isNullOrUndefined, isObject, isString } from '../helpers/type.helper'; import { TaskManager } from './task.manager'; @@ -11,35 +9,88 @@ export class TaskProvider implements ITaskRepository { findById(id: string): Promise { const todoManager = new TaskManager(this.logger); const tasks = todoManager.listTasks(); - const task = tasks.find( - (t) => typeof t === 'object' && (t as Record)['id'] === id, - ) as ITask | null; - return Promise.resolve(task); + const task: ITask | undefined = tasks.find( + (t) => isObject(t) && (t as Record)['id'] === id, + ); + return Promise.resolve(task ?? null); } + /** + * Finds the next eligible task for processing. + * Eligible tasks are those that are either pending or have no state defined. + * Optional filters can be applied to further refine the selection. + * @param _filters Optional array of filters to apply when searching for tasks. + * @return A promise that resolves to the next eligible task or null if none found. + */ findNextEligible(_filters?: string[]): Promise { const todoManager = new TaskManager(this.logger); const tasks = todoManager.listTasks(); - const eligible = tasks.filter( - (t) => - typeof t === 'object' && - typeof (t as Record)['id'] === 'string' && - (isNullOrUndefined((t as Record)['state']) || - (t as Record)['state'] === 'pending'), - ); + const eligibleTasks = this.filterEligibleTasks(tasks); + // Apply filters if any // For now, return first - return Promise.resolve(eligible.length > 0 ? (eligible[0] as ITask) : null); + const firstEligible = eligibleTasks.at(0) ?? null; + return Promise.resolve(firstEligible); } + /** + * Updates an existing task. + * @param task The task to update. + * @returns A promise that resolves when the update is complete. + */ update(task: ITask): Promise { const todoManager = new TaskManager(this.logger); todoManager.updateTaskById(task.id, task); return Promise.resolve(); } + /** + * Retrieves all tasks. + * @returns A promise that resolves to an array of all tasks. + */ findAll(): Promise { const todoManager = new TaskManager(this.logger); - return Promise.resolve(todoManager.listTasks() as ITask[]); + return Promise.resolve(todoManager.listTasks()); + } + + /** + * Filters tasks to find only eligible ones (pending or without state). + * @param tasks Array of tasks to filter. + * @return Array of eligible tasks. + */ + private filterEligibleTasks(tasks: ITask[]): ITask[] { + return tasks.filter((task) => this.isTaskEligible(task)); + } + + /** + * Checks if a task is eligible for processing. + * @param task The task to check. + * @return True if the task is eligible, false otherwise. + */ + private isTaskEligible(task: ITask): boolean { + if (!isObject(task)) return false; + if (!this.hasValidId(task)) return false; + + const state = (task as Record)['state']; + return this.isStateEligible(state); + } + + /** + * Checks if a task has a valid string ID. + * @param task The task to check. + * @return True if the task has a valid ID, false otherwise. + */ + private hasValidId(task: ITask): boolean { + const id = (task as Record)['id']; + return isString(id); + } + + /** + * Checks if a task state is eligible for processing. + * @param state The state to check. + * @return True if the state is eligible (null, undefined, or 'pending'), false otherwise. + */ + private isStateEligible(state: unknown): boolean { + return isNullOrUndefined(state) || state === TaskState.Pending; } } diff --git a/src/core/system/bootstrap.ts b/src/core/system/bootstrap.ts index a9149bb..36b3092 100644 --- a/src/core/system/bootstrap.ts +++ b/src/core/system/bootstrap.ts @@ -1,7 +1,7 @@ import { ReferenceAuditService } from '../../services/reference-audit.service'; import { TaskRenderService } from '../../services/task-render.service'; import { UidSupersedeService } from '../../services/uid-supersede.service'; -import { SERVICE_KEYS, IServiceRegistry } from '../../types/core'; +import { IServiceRegistry, SERVICE_KEYS } from '../../types/core'; import { Resolver } from '../helpers/uid-resolver'; import { getLogger } from '../system/logger'; diff --git a/src/__tests__/core/observability.logger.test.ts b/src/core/system/observability.logger.test.ts similarity index 91% rename from src/__tests__/core/observability.logger.test.ts rename to src/core/system/observability.logger.test.ts index c8b49da..784cbdf 100644 --- a/src/__tests__/core/observability.logger.test.ts +++ b/src/core/system/observability.logger.test.ts @@ -1,6 +1,6 @@ import pino from 'pino'; -import { ObservabilityLogger } from '../../../src/core/system/observability.logger'; +import { ObservabilityLogger } from './observability.logger'; describe('ObservabilityLogger', () => { let logger: ObservabilityLogger; @@ -67,10 +67,11 @@ describe('ObservabilityLogger', () => { it('should handle performance spans', () => { const startTime = new Date(); const endTime = new Date(startTime.getTime() + 1000); - const correlationId = logger.createCorrelationId(); - expect(() => { + const act = () => { logger.span('test_operation', startTime, endTime, { success: true }); - }).not.toThrow(); + }; + + expect(act).not.toThrow(); }); }); diff --git a/src/errors/uid-resolution.error.ts b/src/errors/uid-resolution.error.ts deleted file mode 100644 index 49a50b0..0000000 --- a/src/errors/uid-resolution.error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isNullOrUndefined } from '../core/helpers/type-guards'; - -import { DomainError } from './domain.error'; - -export class UidResolutionError extends DomainError { - readonly code = 'UID_RESOLUTION_ERROR'; - - constructor(uid: string, reason?: string) { - super(`Failed to resolve UID '${uid}'${!isNullOrUndefined(reason) ? `: ${reason}` : ''}`); - Object.setPrototypeOf(this, UidResolutionError.prototype); - } -} diff --git a/src/errors/uid-status.error.ts b/src/errors/uid-status.error.ts deleted file mode 100644 index bdcad1b..0000000 --- a/src/errors/uid-status.error.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DomainError } from './domain.error'; - -export class UidStatusError extends DomainError { - readonly code = 'UID_STATUS_ERROR'; - - constructor(uid: string, status: string) { - super(`UID '${uid}' has invalid status: ${status}`); - Object.setPrototypeOf(this, UidStatusError.prototype); - } -} diff --git a/src/errors/validation.error.ts b/src/errors/validation.error.ts deleted file mode 100644 index dfbe1c8..0000000 --- a/src/errors/validation.error.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DomainError } from './domain.error'; - -export class ValidationError extends DomainError { - readonly code = 'VALIDATION_ERROR'; - - constructor( - message: string, - public readonly field?: string, - ) { - super(message); - Object.setPrototypeOf(this, ValidationError.prototype); - } -} diff --git a/src/services/reference-audit.service.ts b/src/services/reference-audit.service.ts index a17b8a4..4e03c07 100644 --- a/src/services/reference-audit.service.ts +++ b/src/services/reference-audit.service.ts @@ -1,11 +1,17 @@ -import type { IReferenceAuditUseCase, IReferenceAuditResult } from '../types/audit'; -import type { IResolver } from '../types/repository'; -import { isNullOrUndefined } from '../core/helpers/type-guards'; -import { UidStatus } from '../types/audit'; +import { isNullOrUndefined } from '../core/helpers/type.helper'; +import { IReferenceAuditResult, IReferenceAuditUseCase, IResolver, UidStatus } from '../types'; export class ReferenceAuditService implements IReferenceAuditUseCase { + /** + * Creates a new ReferenceAuditService instance. + * @param resolver - The resolver instance for accessing the UID registry + */ constructor(private readonly resolver: IResolver) {} + /** + * Executes the reference audit operation to analyze UID status and references. + * @returns Promise that resolves to the audit result containing statistics and categorized UIDs + */ execute(): Promise { const registry = this.resolver.getRegistry(); let totalReferences = 0; diff --git a/src/services/task-render.service.ts b/src/services/task-render.service.ts index d781add..87b537e 100644 --- a/src/services/task-render.service.ts +++ b/src/services/task-render.service.ts @@ -2,11 +2,15 @@ import { Resolver } from '../core/helpers/uid-resolver'; import { TaskHydrationService } from '../core/processing/hydrate'; import { Renderer } from '../core/rendering/renderer'; import { TaskProviderFactory } from '../core/storage'; -import { ITaskRenderUseCase, ILogger, IRenderOptions, TaskProviderType } from '../types'; +import { ILogger, IRenderOptions, ITaskRenderUseCase, TaskProviderType } from '../types'; export class TaskRenderService implements ITaskRenderUseCase { private readonly hydrationService: TaskHydrationService; + /** + * Creates a new TaskRenderService instance. + * @param logger - Logger instance for logging operations + */ constructor(private readonly logger: ILogger) { const dddKitPath = process.env['DDDKIT_PATH'] ?? '.'; const targetPath = process.env['TARGET_REPO_PATH'] ?? '.'; @@ -15,6 +19,13 @@ export class TaskRenderService implements ITaskRenderUseCase { this.hydrationService = new TaskHydrationService(resolver, renderer, logger); } + /** + * Executes the task rendering process. + * @param taskId - The ID of the task to render + * @param options - Rendering options including pin configuration + * @returns Promise that resolves when rendering is complete + * @throws Error if the task is not found + */ async execute(taskId: string, options: IRenderOptions): Promise { const provider = TaskProviderFactory.create(TaskProviderType.TASK, this.logger); const task = await provider.findById(taskId); diff --git a/src/services/task-validation.service.ts b/src/services/task-validation.service.ts index ecf0e23..11efd16 100644 --- a/src/services/task-validation.service.ts +++ b/src/services/task-validation.service.ts @@ -1,11 +1,10 @@ -import { ILogger } from '../types/observability'; -import { ITask, ITaskStore } from '../types/tasks'; import { TaskProcessor } from '../core/processing/task.processor'; +import { TaskPersistenceService } from '../core/services/task-persistence.service'; +import { TaskValidationProcessorService } from '../core/services/task-validation-processor.service'; +import { ITask, IValidationOptions } from '../types'; import { ValidationContext } from '../validators/validation.context'; import { ValidationFactory } from '../validators/validation.factory'; import { ValidationResult } from '../validators/validation.result'; -import { TaskValidationService as TaskValidationProcessorService } from '../core/services/task-validation-processor.service'; -import { TaskPersistenceService } from '../core/services/task-persistence.service'; /** * Main service for orchestrating task validation and fixing operations. @@ -19,17 +18,12 @@ export class TaskValidationService { */ async validateAndFixTasks( tasks: ITask[], - options: { - applyFixes: boolean; - excludePattern?: string; - store?: ITaskStore; - logger?: ILogger; - }, + options: IValidationOptions, ): Promise { const context = new ValidationContext(tasks, options); const validator = ValidationFactory.createValidator(); - const fixer = ValidationFactory.createFixer(context.getLogger()); + const fixer = ValidationFactory.createFixer(); const exclusionFilter = ValidationFactory.createExclusionFilter(options.excludePattern); const resultBuilder = ValidationFactory.createResultBuilder(); diff --git a/src/services/uid-supersede.service.ts b/src/services/uid-supersede.service.ts index 9c92012..667a550 100644 --- a/src/services/uid-supersede.service.ts +++ b/src/services/uid-supersede.service.ts @@ -2,8 +2,18 @@ import type { IUIdSupersedeUseCase } from '../types/audit'; import type { IResolver } from '../types/repository'; export class UidSupersedeService implements IUIdSupersedeUseCase { + /** + * Creates a new UidSupersedeService instance. + * @param resolver - The resolver instance for managing UID aliases + */ constructor(private readonly resolver: IResolver) {} + /** + * Executes the UID supersede operation by updating the alias mapping. + * @param oldUid - The old UID to be superseded + * @param newUid - The new UID to replace the old one + * @returns Promise that resolves when the operation is complete + */ execute(oldUid: string, newUid: string): Promise { this.resolver.updateAlias(oldUid, newUid); // Note: This is in-memory only; for persistence, write back to aliases.json. diff --git a/src/types/commands/CommandName.ts b/src/types/commands/CommandName.ts index e336612..543167a 100644 --- a/src/types/commands/CommandName.ts +++ b/src/types/commands/CommandName.ts @@ -2,9 +2,11 @@ export enum CommandName { ADD = 'add', AUDIT = 'audit', COMPLETE = 'complete', + FIX = 'fix', LIST = 'list', NEXT = 'next', RENDER = 'render', SHOW = 'show', SUPERSEDE = 'supersede', + VALIDATE = 'validate', } diff --git a/src/types/commands/ICommand.ts b/src/types/commands/ICommand.ts index dcc36da..81536fd 100644 --- a/src/types/commands/ICommand.ts +++ b/src/types/commands/ICommand.ts @@ -1,5 +1,7 @@ +import { CommandName } from './CommandName'; + export interface ICommand { - readonly name: string; + readonly name: CommandName; readonly description: string; execute(args: TArgs): Promise; diff --git a/src/types/commands/IOperationContext.ts b/src/types/commands/IOperationContext.ts new file mode 100644 index 0000000..c7cbdb7 --- /dev/null +++ b/src/types/commands/IOperationContext.ts @@ -0,0 +1,7 @@ +import { IObservabilityLogger } from '../observability'; + +export interface IOperationContext { + operationLogger: IObservabilityLogger; + startTime: Date; + stopTimer: () => void; +} diff --git a/src/types/commands/ISupersedeOptions.ts b/src/types/commands/ISupersedeOptions.ts new file mode 100644 index 0000000..af5338f --- /dev/null +++ b/src/types/commands/ISupersedeOptions.ts @@ -0,0 +1,8 @@ +/** + * Audit command types + */ + +export interface ISupersedeOptions { + oldUid: string; + newUid: string; +} diff --git a/src/types/commands/TaskDetails.ts b/src/types/commands/TaskDetails.ts new file mode 100644 index 0000000..f3d0967 --- /dev/null +++ b/src/types/commands/TaskDetails.ts @@ -0,0 +1,4 @@ +export interface TaskDetails { + detailed_requirements?: unknown; + validations?: unknown; +} diff --git a/src/types/commands/TodoShowCommandArgs.ts b/src/types/commands/TodoShowCommandArgs.ts new file mode 100644 index 0000000..64b7a84 --- /dev/null +++ b/src/types/commands/TodoShowCommandArgs.ts @@ -0,0 +1,3 @@ +export interface TodoShowCommandArgs { + id: string; +} diff --git a/src/types/commands/index.ts b/src/types/commands/index.ts index a50a8ba..99648f8 100644 --- a/src/types/commands/index.ts +++ b/src/types/commands/index.ts @@ -1,2 +1,8 @@ export type { ICommand } from './ICommand'; export { CommandName } from './CommandName'; + +// Command-specific types +export * from './IOperationContext'; +export * from './ISupersedeOptions'; +export * from './TaskDetails'; +export * from './TodoShowCommandArgs'; diff --git a/src/errors/domain.error.ts b/src/types/core/errors.ts similarity index 57% rename from src/errors/domain.error.ts rename to src/types/core/errors.ts index 6967601..e4ad51b 100644 --- a/src/errors/domain.error.ts +++ b/src/types/core/errors.ts @@ -1,3 +1,5 @@ +import { isNullOrUndefined } from '../../core/helpers/type.helper'; + /** * Domain-specific error types for the DDD-Kit system. * Following clean architecture principles with domain-specific exceptions. @@ -47,3 +49,37 @@ export abstract class DomainError extends Error { }; } } + +export class ValidationError extends DomainError { + readonly code = 'VALIDATION_ERROR'; + + constructor( + message: string, + public readonly field?: string, + ) { + super(message); + Object.setPrototypeOf(this, ValidationError.prototype); + } +} + +export class UidStatusError extends DomainError { + readonly code = 'UID_STATUS_ERROR'; + + constructor(uid: string, status: string) { + super(`UID '${uid}' has invalid status: ${status}`); + Object.setPrototypeOf(this, UidStatusError.prototype); + } +} + +export class UidResolutionError extends DomainError { + readonly code = 'UID_RESOLUTION_ERROR'; + + constructor(uid: string, reason?: string) { + let message = `Failed to resolve UID '${uid}'`; + if (!isNullOrUndefined(reason) && reason.trim().length > 0) { + message += `: ${reason}`; + } + super(message); + Object.setPrototypeOf(this, UidResolutionError.prototype); + } +} diff --git a/src/constants/exit-codes.ts b/src/types/core/exit-codes.ts similarity index 100% rename from src/constants/exit-codes.ts rename to src/types/core/exit-codes.ts diff --git a/src/types/core/index.ts b/src/types/core/index.ts index ec19cdd..08505da 100644 --- a/src/types/core/index.ts +++ b/src/types/core/index.ts @@ -3,3 +3,7 @@ export * from './IFileManager'; export * from './IServiceRegistry'; export * from './IServiceResolver'; export * from './SERVICE_KEYS'; +export * from './exit-codes'; +export * from './errors'; +export * from './registry'; +export * from './parsers'; diff --git a/src/types/core/parsers.ts b/src/types/core/parsers.ts new file mode 100644 index 0000000..b2610b7 --- /dev/null +++ b/src/types/core/parsers.ts @@ -0,0 +1,15 @@ +import type { ILogger } from '../observability'; + +import type { IFileManager } from './IFileManager'; + +/** + * Parser-related types for file processing operations + */ + +export interface UpdateYamlBlockOptions { + filePath: string; + id: string; + updatedData: Record; + fileSystem: IFileManager; + logger?: ILogger; +} diff --git a/src/types/core/registry.ts b/src/types/core/registry.ts new file mode 100644 index 0000000..e7ba6d8 --- /dev/null +++ b/src/types/core/registry.ts @@ -0,0 +1,16 @@ +/** + * Registry-related types for UID resolution and dependency management + */ + +export interface IRegistryEntry { + path: string; + status: string; + sha: string; + aliases: string[]; + requires: string[]; +} + +export interface RegistryEntryDetails { + status: string; + requires: string[]; +} diff --git a/src/types/rendering/IOutputWriter.ts b/src/types/rendering/IOutputWriter.ts new file mode 100644 index 0000000..a5c63b8 --- /dev/null +++ b/src/types/rendering/IOutputWriter.ts @@ -0,0 +1,64 @@ +import { OutputFormat } from './OutputFormat'; + +/** + * Interface for CLI output handling. + * + * Provides a consistent API for writing different types of messages to the console + * with appropriate formatting, colors, and structure. + */ +export interface IOutputWriter { + /** + * Writes a success message with green coloring. + * @param message - The success message to display + */ + success(message: string): void; + + /** + * Writes an informational message. + * @param message - The info message to display + */ + info(message: string): void; + + /** + * Writes a warning message with yellow coloring. + * @param message - The warning message to display + */ + warning(message: string): void; + + /** + * Writes an error message with red coloring. + * @param message - The error message to display + */ + error(message: string): void; + + /** + * Writes a plain message without any formatting. + * @param message - The message to display + */ + write(message: string): void; + + /** + * Writes a line break. + */ + newline(): void; + + /** + * Writes structured data in the specified format. + * @param data - The data to format and display + * @param format - The output format (json, csv, or table) + */ + writeFormatted(data: unknown, format: OutputFormat): void; + + /** + * Writes a section header with bold formatting. + * @param title - The section title + */ + section(title: string): void; + + /** + * Writes a key-value pair with aligned formatting. + * @param key - The key/label + * @param value - The value + */ + keyValue(key: string, value: string): void; +} diff --git a/src/types/rendering/OutputFormat.ts b/src/types/rendering/OutputFormat.ts index 42f65c8..2c96013 100644 --- a/src/types/rendering/OutputFormat.ts +++ b/src/types/rendering/OutputFormat.ts @@ -5,4 +5,5 @@ export enum OutputFormat { JSON = 'json', CSV = 'csv', + TABLE = 'table', } diff --git a/src/types/rendering/index.ts b/src/types/rendering/index.ts index c746e65..db8e03b 100644 --- a/src/types/rendering/index.ts +++ b/src/types/rendering/index.ts @@ -2,3 +2,4 @@ export * from './IRenderer'; export * from './OutputFormat'; export * from './RenderCommandOptions'; export * from './NextCommandOptions'; +export * from './IOutputWriter'; diff --git a/src/core/storage/projects.types.ts b/src/types/repository/github.ts similarity index 52% rename from src/core/storage/projects.types.ts rename to src/types/repository/github.ts index 6fce542..a1fb3f5 100644 --- a/src/core/storage/projects.types.ts +++ b/src/types/repository/github.ts @@ -1,3 +1,60 @@ +import { isObject } from '../../core/helpers/type.helper'; + +/** + * GitHub API types for type safety + */ +interface GitHubUser { + login: string; + id: number; + avatar_url: string; +} + +export interface GitHubLabel { + id: number; + name: string; + color: string; + description: string | null; +} + +interface GitHubMilestone { + id: number; + title: string; + due_on: string | null; + state: 'open' | 'closed'; +} + +interface GitHubIssue { + id: number; + number: number; + title: string; + body: string | null; + state: 'open' | 'closed'; + created_at: string; + updated_at: string; + assignee: GitHubUser | null; + assignees: GitHubUser[]; + labels: GitHubLabel[]; + milestone: GitHubMilestone | null; + pull_request?: { + url: string; + html_url: string; + }; +} + +/** + * Type guard to check if an unknown value is a GitHub issue + */ +export function isGitHubIssue(value: unknown): value is GitHubIssue { + return ( + isObject(value) && + 'number' in value && + 'title' in value && + 'state' in value && + 'created_at' in value && + 'updated_at' in value + ); +} + /** * GitHub Projects v2 API types for type safety */ diff --git a/src/types/repository/index.ts b/src/types/repository/index.ts index 2131492..ffd22bd 100644 --- a/src/types/repository/index.ts +++ b/src/types/repository/index.ts @@ -1,3 +1,4 @@ export * from './ITaskRepository'; export * from './IResolver'; export * from './IExclusionFilter'; +export * from './github'; diff --git a/src/types/tasks/ITask.ts b/src/types/tasks/ITask.ts index 88bcf1a..219883f 100644 --- a/src/types/tasks/ITask.ts +++ b/src/types/tasks/ITask.ts @@ -1,4 +1,5 @@ import { IResolvedReference } from './IResolvedReference'; +import { TaskPriority } from './TaskPriority'; import { TaskState } from './TaskState'; import { TaskStatus } from './TaskStatus'; @@ -8,6 +9,7 @@ export interface ITask { title?: string; state?: TaskState; status?: TaskStatus; + priority?: TaskPriority; references?: string[]; owner?: string; due?: string; diff --git a/src/types/tasks/ITaskFixer.ts b/src/types/tasks/ITaskFixer.ts index 78fa5b1..f8988e8 100644 --- a/src/types/tasks/ITaskFixer.ts +++ b/src/types/tasks/ITaskFixer.ts @@ -1,5 +1,5 @@ -import type { FixRecord } from './FixRecord'; import { ITask } from './ITask'; +import { TaskFixResult } from './TaskFixResult'; /** * Interface for automatically fixing common task validation issues. @@ -27,7 +27,7 @@ export interface ITaskFixer { * dates, normalizing status values, or adding required default fields. * * @param task - The task object to analyze and fix - * @returns Array of FixRecord objects describing what fixes were applied + * @returns Object containing the fixed task and array of FixRecord objects describing what fixes were applied * * @example * ```typescript @@ -37,12 +37,10 @@ export interface ITaskFixer { * priority: '', // Empty string should be null * }; * - * const fixes = fixer.applyBasicFixes(brokenTask); - * // fixes might include: - * // - Trimmed whitespace from title - * // - Normalized status to 'pending' - * // - Set priority to null + * const result = fixer.applyBasicFixes(brokenTask); + * // result.fixedTask has the corrected task + * // result.fixes contains descriptions of applied fixes * ``` */ - applyBasicFixes(task: ITask): FixRecord[]; + applyBasicFixes(task: ITask): TaskFixResult; } diff --git a/src/types/tasks/ITaskHydrationUseCase.ts b/src/types/tasks/ITaskHydrationUseCase.ts index abbe1ae..d224561 100644 --- a/src/types/tasks/ITaskHydrationUseCase.ts +++ b/src/types/tasks/ITaskHydrationUseCase.ts @@ -1,5 +1,5 @@ -import { ITask } from './ITask'; import { IHydrationOptions } from './IHydrationOptions'; +import { ITask } from './ITask'; /** * Use case for hydrating the next eligible task. diff --git a/src/types/tasks/TaskFixResult.ts b/src/types/tasks/TaskFixResult.ts new file mode 100644 index 0000000..848e492 --- /dev/null +++ b/src/types/tasks/TaskFixResult.ts @@ -0,0 +1,19 @@ +import type { FixRecord } from './FixRecord'; +import { ITask } from './ITask'; + +/** + * Result of applying fixes to a task object. + * + * Contains both the corrected task and a record of what fixes were applied. + */ +export interface TaskFixResult { + /** + * The task object with fixes applied. + */ + fixedTask: ITask; + + /** + * Array of records describing what fixes were applied to the task. + */ + fixes: FixRecord[]; +} diff --git a/src/types/tasks/index.ts b/src/types/tasks/index.ts index 6d40cf7..1f9855d 100644 --- a/src/types/tasks/index.ts +++ b/src/types/tasks/index.ts @@ -13,6 +13,7 @@ export * from './TaskStatus'; export * from './TaskProviderType'; export * from './IFixerOptions'; export * from './FixRecord'; +export * from './TaskFixResult'; // Todo command types (task-specific commands) export * from './AddTaskArgs'; diff --git a/src/types/validation/IValidationOptions.ts b/src/types/validation/IValidationOptions.ts new file mode 100644 index 0000000..edccfab --- /dev/null +++ b/src/types/validation/IValidationOptions.ts @@ -0,0 +1,12 @@ +import { ILogger } from '../observability'; +import { ITaskStore } from '../tasks'; + +/** + * Options for validating and fixing tasks. + */ +export interface IValidationOptions { + applyFixes: boolean; + excludePattern?: string; + store?: ITaskStore; + logger?: ILogger; +} diff --git a/src/types/validation/index.ts b/src/types/validation/index.ts index 2be9f15..9649d79 100644 --- a/src/types/validation/index.ts +++ b/src/types/validation/index.ts @@ -1,3 +1,4 @@ +export * from './IValidationOptions'; export * from './IValidationResult'; export * from './IValidationResultBuilder'; export * from './ValidateFixCommandOptions'; diff --git a/src/validators/schema.loader.ts b/src/validators/schema.loader.ts index 91876b1..d1b0243 100644 --- a/src/validators/schema.loader.ts +++ b/src/validators/schema.loader.ts @@ -1,7 +1,7 @@ import * as path from 'path'; -import { FileManager } from '../core/storage/file-manager'; import { parseJsonFile } from '../core/parsers/json.parser'; +import { FileManager } from '../core/storage/file-manager'; /** * Class responsible for loading JSON schema files from the filesystem. diff --git a/src/validators/validation-result.builder.ts b/src/validators/validation-result.builder.ts index 126852a..791bb38 100644 --- a/src/validators/validation-result.builder.ts +++ b/src/validators/validation-result.builder.ts @@ -1,4 +1,4 @@ -import { IValidationResultBuilder, FixRecord } from '../types'; +import { FixRecord, IValidationResultBuilder } from '../types'; import { ValidationResult } from './validation.result'; diff --git a/src/validators/validation.context.ts b/src/validators/validation.context.ts index b608244..7bfc5c9 100644 --- a/src/validators/validation.context.ts +++ b/src/validators/validation.context.ts @@ -1,7 +1,8 @@ import { DefaultTaskStore } from '../core/storage/default-task.store'; import { getLogger } from '../core/system/logger'; -import { ITaskStore } from '../types/tasks'; +import { IValidationOptions } from '../types'; import { ILogger } from '../types/observability'; +import { ITask, ITaskStore } from '../types/tasks'; /** * Context object for task validation operations. @@ -13,13 +14,8 @@ export class ValidationContext { * @param options - Options for the validation context. */ constructor( - public readonly tasks: unknown[], - public readonly options: { - applyFixes: boolean; - excludePattern?: string; - store?: ITaskStore; - logger?: ILogger; - }, + public readonly tasks: ITask[], + public readonly options: IValidationOptions, ) {} /** diff --git a/src/validators/validation.factory.ts b/src/validators/validation.factory.ts index 1a15317..ac30d89 100644 --- a/src/validators/validation.factory.ts +++ b/src/validators/validation.factory.ts @@ -1,13 +1,12 @@ -import { ILogger } from '../types/observability'; +import { TaskFixer } from '../core/fixers/task.fixer'; +import { ExclusionFilter } from '../core/processing/exclusion.filter'; import { IExclusionFilter } from '../types/repository'; import { ITaskFixer, ITaskValidator } from '../types/tasks'; import { IValidationResultBuilder } from '../types/validation'; -import { TaskFixer } from '../core/fixers/task.fixer'; -import { ExclusionFilter } from '../core/processing/exclusion.filter'; +import { AjvValidator } from './ajv.validator'; import { SchemaLoader } from './schema.loader'; import { ValidationResultBuilder } from './validation-result.builder'; -import { AjvValidator } from './ajv.validator'; /** * Factory for creating validation dependencies. @@ -27,7 +26,7 @@ export class ValidationFactory { * @param logger - The logger instance to use for logging fix operations. * @returns A configured ITaskFixer instance ready for applying automatic fixes. */ - static createFixer(_logger: ILogger): ITaskFixer { + static createFixer(): ITaskFixer { return new TaskFixer(); } diff --git a/src/validators/validator.ts b/src/validators/validator.ts index 19ff10a..207e1a3 100644 --- a/src/validators/validator.ts +++ b/src/validators/validator.ts @@ -1,13 +1,11 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; -import { ITaskStore, ITask } from '../types/tasks'; -import { ILogger } from '../types/observability'; import { TaskValidationService } from '../services/task-validation.service'; -import { IValidationResult } from '../types'; +import { IValidationOptions, IValidationResult, ITask } from '../types'; -import { SchemaLoader } from './schema.loader'; import { AjvValidator } from './ajv.validator'; +import { SchemaLoader } from './schema.loader'; const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); @@ -63,10 +61,11 @@ export function validateTasks(tasks: ITask[]): IValidationResult { * 4. Detailed reporting of validation results and applied fixes * * @param tasks - Array of Task objects to validate and potentially fix - * @param applyFixes - Whether to actually apply the fixes to the task store (true) or just report them (false) - * @param excludePattern - Optional glob pattern to exclude certain tasks from validation/fixes (e.g., "T-001") - * @param store - Optional custom task store implementation for persisting changes (defaults to DefaultTaskStore) - * @param logger - Optional logger instance for debugging and progress reporting + * @param options - Options for the validation and fixing operation: + * - applyFixes: boolean indicating if fixes should be applied to the task store + * - excludePattern?: optional regex pattern to exclude certain tasks from validation/fixing + * - store?: optional ITaskStore instance to use for persisting fixes (defaults to in-memory store) + * - logger?: optional ILogger instance for logging (defaults to console logger) * @returns Promise resolving to an object containing: * - valid: boolean indicating if all tasks passed validation (after fixes) * - errors: array of remaining validation error messages (only present if valid is false) @@ -75,12 +74,7 @@ export function validateTasks(tasks: ITask[]): IValidationResult { */ export async function validateAndFixTasks( tasks: ITask[], - options: { - applyFixes: boolean; - excludePattern?: string; - store?: ITaskStore; - logger?: ILogger; - }, + options: IValidationOptions, ): Promise { const service = new TaskValidationService(); const result = await service.validateAndFixTasks(tasks, options);