From 2ae1ac13f586b0dfb7a92c201269827b0d3964d2 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 1 Dec 2025 02:23:36 +0500 Subject: [PATCH] feat(ipuaro): add analysis tools (v0.8.0) - GetDependenciesTool: get files a file imports - GetDependentsTool: get files that import a file - GetComplexityTool: get complexity metrics - GetTodosTool: find TODO/FIXME/HACK comments Tests: 853 (+120), Coverage: 97.91% --- packages/ipuaro/CHANGELOG.md | 43 ++ packages/ipuaro/package.json | 2 +- .../tools/analysis/GetComplexityTool.ts | 232 +++++++ .../tools/analysis/GetDependenciesTool.ts | 121 ++++ .../tools/analysis/GetDependentsTool.ts | 124 ++++ .../tools/analysis/GetTodosTool.ts | 276 +++++++++ .../infrastructure/tools/analysis/index.ts | 20 + .../ipuaro/src/infrastructure/tools/index.ts | 26 + .../tools/analysis/GetComplexityTool.test.ts | 513 +++++++++++++++ .../analysis/GetDependenciesTool.test.ts | 342 ++++++++++ .../tools/analysis/GetDependentsTool.test.ts | 388 ++++++++++++ .../tools/analysis/GetTodosTool.test.ts | 583 ++++++++++++++++++ 12 files changed, 2669 insertions(+), 1 deletion(-) create mode 100644 packages/ipuaro/src/infrastructure/tools/analysis/GetComplexityTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/analysis/GetDependenciesTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/analysis/GetDependentsTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/analysis/GetTodosTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/analysis/index.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetComplexityTool.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependenciesTool.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependentsTool.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetTodosTool.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 0e9fd91..c9b1f2d 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2025-12-01 - Analysis Tools + +### Added + +- **GetDependenciesTool (0.8.1)** + - `get_dependencies(path)`: Get files that a specific file imports + - Returns internal dependencies resolved to file paths + - Includes metadata: exists, isHub, isEntryPoint, fileType + - Sorted by path for consistent output + - 23 unit tests + +- **GetDependentsTool (0.8.2)** + - `get_dependents(path)`: Get files that import a specific file + - Shows hub status for the analyzed file + - Includes metadata: isHub, isEntryPoint, fileType, complexityScore + - Sorted by path for consistent output + - 24 unit tests + +- **GetComplexityTool (0.8.3)** + - `get_complexity(path?, limit?)`: Get complexity metrics for files + - Returns LOC, nesting depth, cyclomatic complexity, and overall score + - Summary statistics: high/medium/low complexity counts + - Average score calculation + - Sorted by complexity score descending + - Default limit of 20 files + - 31 unit tests + +- **GetTodosTool (0.8.4)** + - `get_todos(path?, type?)`: Find TODO/FIXME/HACK/XXX/BUG/NOTE comments + - Supports multiple comment styles: `//`, `/* */`, `#` + - Filter by type (case-insensitive) + - Counts by type + - Includes line context + - 42 unit tests + +### Changed + +- Total tests: 853 (was 733) +- Coverage: 97.91% lines, 92.32% branches +- Analysis tools category now fully implemented (4/4 tools) + +--- + ## [0.7.0] - 2025-12-01 - Search Tools ### Added diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index dfc9aa8..bdad7f3 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.7.0", + "version": "0.8.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/infrastructure/tools/analysis/GetComplexityTool.ts b/packages/ipuaro/src/infrastructure/tools/analysis/GetComplexityTool.ts new file mode 100644 index 0000000..e15a7da --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/analysis/GetComplexityTool.ts @@ -0,0 +1,232 @@ +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import type { ComplexityMetrics, FileMeta } from "../../../domain/value-objects/FileMeta.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Complexity entry for a single file. + */ +export interface ComplexityEntry { + /** Relative path to the file */ + path: string + /** Complexity metrics */ + metrics: ComplexityMetrics + /** File type classification */ + fileType: "source" | "test" | "config" | "types" | "unknown" + /** Whether the file is a hub */ + isHub: boolean +} + +/** + * Result data from get_complexity tool. + */ +export interface GetComplexityResult { + /** The path that was analyzed (file or directory) */ + analyzedPath: string | null + /** Total files analyzed */ + totalFiles: number + /** Average complexity score */ + averageScore: number + /** Files sorted by complexity score (descending) */ + files: ComplexityEntry[] + /** Summary statistics */ + summary: { + highComplexity: number + mediumComplexity: number + lowComplexity: number + } +} + +/** + * Complexity thresholds for classification. + */ +const COMPLEXITY_THRESHOLDS = { + high: 60, + medium: 30, +} + +/** + * Tool for getting complexity metrics for files. + * Can analyze a single file or all files in the project. + */ +export class GetComplexityTool implements ITool { + readonly name = "get_complexity" + readonly description = + "Get complexity metrics for files. " + + "Returns LOC, nesting depth, cyclomatic complexity, and overall score. " + + "Without path, returns all files sorted by complexity." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File or directory path to analyze (optional, defaults to entire project)", + required: false, + }, + { + name: "limit", + type: "number", + description: "Maximum number of files to return (default: 20)", + required: false, + default: 20, + }, + ] + readonly requiresConfirmation = false + readonly category = "analysis" as const + + validateParams(params: Record): string | null { + if (params.path !== undefined && typeof params.path !== "string") { + return "Parameter 'path' must be a string" + } + if (params.limit !== undefined) { + if (typeof params.limit !== "number" || !Number.isInteger(params.limit)) { + return "Parameter 'limit' must be an integer" + } + if (params.limit < 1) { + return "Parameter 'limit' must be at least 1" + } + } + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const inputPath = params.path as string | undefined + const limit = (params.limit as number | undefined) ?? 20 + + try { + const allMetas = await ctx.storage.getAllMetas() + + if (allMetas.size === 0) { + return createSuccessResult( + callId, + { + analyzedPath: inputPath ?? null, + totalFiles: 0, + averageScore: 0, + files: [], + summary: { highComplexity: 0, mediumComplexity: 0, lowComplexity: 0 }, + } satisfies GetComplexityResult, + Date.now() - startTime, + ) + } + + let filteredMetas = allMetas + let analyzedPath: string | null = null + + if (inputPath) { + const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot) + analyzedPath = relativePath + filteredMetas = this.filterByPath(allMetas, relativePath) + + if (filteredMetas.size === 0) { + return createErrorResult( + callId, + `No files found at path: ${relativePath}`, + Date.now() - startTime, + ) + } + } + + const entries: ComplexityEntry[] = [] + for (const [filePath, meta] of filteredMetas) { + entries.push({ + path: filePath, + metrics: meta.complexity, + fileType: meta.fileType, + isHub: meta.isHub, + }) + } + + entries.sort((a, b) => b.metrics.score - a.metrics.score) + + const summary = this.calculateSummary(entries) + const averageScore = this.calculateAverageScore(entries) + + const limitedEntries = entries.slice(0, limit) + + const result: GetComplexityResult = { + analyzedPath, + totalFiles: entries.length, + averageScore, + files: limitedEntries, + summary, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Normalize input path to relative path from project root. + */ + private normalizePathToRelative(inputPath: string, projectRoot: string): string { + if (path.isAbsolute(inputPath)) { + return path.relative(projectRoot, inputPath) + } + return inputPath + } + + /** + * Filter metas by path prefix (file or directory). + */ + private filterByPath( + allMetas: Map, + targetPath: string, + ): Map { + const filtered = new Map() + + for (const [filePath, meta] of allMetas) { + if (filePath === targetPath || filePath.startsWith(`${targetPath}/`)) { + filtered.set(filePath, meta) + } + } + + return filtered + } + + /** + * Calculate summary statistics for complexity entries. + */ + private calculateSummary(entries: ComplexityEntry[]): { + highComplexity: number + mediumComplexity: number + lowComplexity: number + } { + let high = 0 + let medium = 0 + let low = 0 + + for (const entry of entries) { + const score = entry.metrics.score + if (score >= COMPLEXITY_THRESHOLDS.high) { + high++ + } else if (score >= COMPLEXITY_THRESHOLDS.medium) { + medium++ + } else { + low++ + } + } + + return { highComplexity: high, mediumComplexity: medium, lowComplexity: low } + } + + /** + * Calculate average complexity score. + */ + private calculateAverageScore(entries: ComplexityEntry[]): number { + if (entries.length === 0) { + return 0 + } + const total = entries.reduce((sum, entry) => sum + entry.metrics.score, 0) + return Math.round((total / entries.length) * 100) / 100 + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/analysis/GetDependenciesTool.ts b/packages/ipuaro/src/infrastructure/tools/analysis/GetDependenciesTool.ts new file mode 100644 index 0000000..89167e6 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/analysis/GetDependenciesTool.ts @@ -0,0 +1,121 @@ +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Single dependency entry with metadata. + */ +export interface DependencyEntry { + /** Relative path to the dependency */ + path: string + /** Whether the file exists in the project */ + exists: boolean + /** Whether it's an entry point */ + isEntryPoint: boolean + /** Whether it's a hub file */ + isHub: boolean + /** File type classification */ + fileType: "source" | "test" | "config" | "types" | "unknown" +} + +/** + * Result data from get_dependencies tool. + */ +export interface GetDependenciesResult { + /** The file being analyzed */ + file: string + /** Total number of dependencies */ + totalDependencies: number + /** List of dependencies with metadata */ + dependencies: DependencyEntry[] + /** File type of the source file */ + fileType: "source" | "test" | "config" | "types" | "unknown" +} + +/** + * Tool for getting files that a specific file imports. + * Returns the list of internal dependencies from FileMeta. + */ +export class GetDependenciesTool implements ITool { + readonly name = "get_dependencies" + readonly description = + "Get files that a specific file imports. " + + "Returns internal dependencies resolved to file paths." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path to analyze (relative to project root or absolute)", + required: true, + }, + ] + readonly requiresConfirmation = false + readonly category = "analysis" as const + + validateParams(params: Record): string | null { + if (typeof params.path !== "string" || params.path.trim() === "") { + return "Parameter 'path' is required and must be a non-empty string" + } + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const inputPath = (params.path as string).trim() + + try { + const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot) + + const meta = await ctx.storage.getMeta(relativePath) + if (!meta) { + return createErrorResult( + callId, + `File not found or not indexed: ${relativePath}`, + Date.now() - startTime, + ) + } + + const dependencies: DependencyEntry[] = [] + for (const depPath of meta.dependencies) { + const depMeta = await ctx.storage.getMeta(depPath) + dependencies.push({ + path: depPath, + exists: depMeta !== null, + isEntryPoint: depMeta?.isEntryPoint ?? false, + isHub: depMeta?.isHub ?? false, + fileType: depMeta?.fileType ?? "unknown", + }) + } + + dependencies.sort((a, b) => a.path.localeCompare(b.path)) + + const result: GetDependenciesResult = { + file: relativePath, + totalDependencies: dependencies.length, + dependencies, + fileType: meta.fileType, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Normalize input path to relative path from project root. + */ + private normalizePathToRelative(inputPath: string, projectRoot: string): string { + if (path.isAbsolute(inputPath)) { + return path.relative(projectRoot, inputPath) + } + return inputPath + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/analysis/GetDependentsTool.ts b/packages/ipuaro/src/infrastructure/tools/analysis/GetDependentsTool.ts new file mode 100644 index 0000000..1187e1e --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/analysis/GetDependentsTool.ts @@ -0,0 +1,124 @@ +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Single dependent entry with metadata. + */ +export interface DependentEntry { + /** Relative path to the dependent file */ + path: string + /** Whether the file is an entry point */ + isEntryPoint: boolean + /** Whether the file is a hub */ + isHub: boolean + /** File type classification */ + fileType: "source" | "test" | "config" | "types" | "unknown" + /** Complexity score of the dependent */ + complexityScore: number +} + +/** + * Result data from get_dependents tool. + */ +export interface GetDependentsResult { + /** The file being analyzed */ + file: string + /** Total number of dependents */ + totalDependents: number + /** Whether this file is a hub (>5 dependents) */ + isHub: boolean + /** List of files that import this file */ + dependents: DependentEntry[] + /** File type of the source file */ + fileType: "source" | "test" | "config" | "types" | "unknown" +} + +/** + * Tool for getting files that import a specific file. + * Returns the list of files that depend on the target file. + */ +export class GetDependentsTool implements ITool { + readonly name = "get_dependents" + readonly description = + "Get files that import a specific file. " + + "Returns list of files that depend on the target." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path to analyze (relative to project root or absolute)", + required: true, + }, + ] + readonly requiresConfirmation = false + readonly category = "analysis" as const + + validateParams(params: Record): string | null { + if (typeof params.path !== "string" || params.path.trim() === "") { + return "Parameter 'path' is required and must be a non-empty string" + } + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const inputPath = (params.path as string).trim() + + try { + const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot) + + const meta = await ctx.storage.getMeta(relativePath) + if (!meta) { + return createErrorResult( + callId, + `File not found or not indexed: ${relativePath}`, + Date.now() - startTime, + ) + } + + const dependents: DependentEntry[] = [] + for (const depPath of meta.dependents) { + const depMeta = await ctx.storage.getMeta(depPath) + dependents.push({ + path: depPath, + isEntryPoint: depMeta?.isEntryPoint ?? false, + isHub: depMeta?.isHub ?? false, + fileType: depMeta?.fileType ?? "unknown", + complexityScore: depMeta?.complexity.score ?? 0, + }) + } + + dependents.sort((a, b) => a.path.localeCompare(b.path)) + + const result: GetDependentsResult = { + file: relativePath, + totalDependents: dependents.length, + isHub: meta.isHub, + dependents, + fileType: meta.fileType, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Normalize input path to relative path from project root. + */ + private normalizePathToRelative(inputPath: string, projectRoot: string): string { + if (path.isAbsolute(inputPath)) { + return path.relative(projectRoot, inputPath) + } + return inputPath + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/analysis/GetTodosTool.ts b/packages/ipuaro/src/infrastructure/tools/analysis/GetTodosTool.ts new file mode 100644 index 0000000..13b1fd9 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/analysis/GetTodosTool.ts @@ -0,0 +1,276 @@ +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import type { FileData } from "../../../domain/value-objects/FileData.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Types of TODO markers to search for. + */ +export type TodoType = "TODO" | "FIXME" | "HACK" | "XXX" | "BUG" | "NOTE" + +/** + * A single TODO entry found in the codebase. + */ +export interface TodoEntry { + /** Relative path to the file */ + path: string + /** Line number where the TODO is found */ + line: number + /** Type of TODO marker (TODO, FIXME, etc.) */ + type: TodoType + /** The TODO text content */ + text: string + /** Full line content for context */ + context: string +} + +/** + * Result data from get_todos tool. + */ +export interface GetTodosResult { + /** The path that was searched (file or directory) */ + searchedPath: string | null + /** Total number of TODOs found */ + totalTodos: number + /** Number of files with TODOs */ + filesWithTodos: number + /** TODOs grouped by type */ + byType: Record + /** List of TODO entries */ + todos: TodoEntry[] +} + +/** + * Supported TODO marker patterns. + */ +const TODO_MARKERS: TodoType[] = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"] + +/** + * Regex pattern for matching TODO markers in comments. + */ +const TODO_PATTERN = new RegExp( + `(?://|/\\*|\\*|#)\\s*(${TODO_MARKERS.join("|")})(?:\\([^)]*\\))?:?\\s*(.*)`, + "i", +) + +/** + * Tool for finding TODO/FIXME/HACK comments in the codebase. + * Searches through indexed files for common task markers. + */ +export class GetTodosTool implements ITool { + readonly name = "get_todos" + readonly description = + "Find TODO, FIXME, HACK, XXX, BUG, and NOTE comments in the codebase. " + + "Returns list of locations with context." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File or directory to search (optional, defaults to entire project)", + required: false, + }, + { + name: "type", + type: "string", + description: + "Filter by TODO type: TODO, FIXME, HACK, XXX, BUG, NOTE (optional, defaults to all)", + required: false, + }, + ] + readonly requiresConfirmation = false + readonly category = "analysis" as const + + validateParams(params: Record): string | null { + if (params.path !== undefined && typeof params.path !== "string") { + return "Parameter 'path' must be a string" + } + if (params.type !== undefined) { + if (typeof params.type !== "string") { + return "Parameter 'type' must be a string" + } + const upperType = params.type.toUpperCase() + if (!TODO_MARKERS.includes(upperType as TodoType)) { + return `Parameter 'type' must be one of: ${TODO_MARKERS.join(", ")}` + } + } + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const inputPath = params.path as string | undefined + const filterType = params.type ? ((params.type as string).toUpperCase() as TodoType) : null + + try { + const allFiles = await ctx.storage.getAllFiles() + + if (allFiles.size === 0) { + return createSuccessResult( + callId, + this.createEmptyResult(inputPath ?? null), + Date.now() - startTime, + ) + } + + let filesToSearch = allFiles + let searchedPath: string | null = null + + if (inputPath) { + const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot) + searchedPath = relativePath + filesToSearch = this.filterByPath(allFiles, relativePath) + + if (filesToSearch.size === 0) { + return createErrorResult( + callId, + `No files found at path: ${relativePath}`, + Date.now() - startTime, + ) + } + } + + const todos: TodoEntry[] = [] + const filesWithTodos = new Set() + + for (const [filePath, fileData] of filesToSearch) { + const fileTodos = this.findTodosInFile(filePath, fileData.lines, filterType) + if (fileTodos.length > 0) { + filesWithTodos.add(filePath) + todos.push(...fileTodos) + } + } + + todos.sort((a, b) => { + const pathCompare = a.path.localeCompare(b.path) + if (pathCompare !== 0) { + return pathCompare + } + return a.line - b.line + }) + + const byType = this.countByType(todos) + + const result: GetTodosResult = { + searchedPath, + totalTodos: todos.length, + filesWithTodos: filesWithTodos.size, + byType, + todos, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Normalize input path to relative path from project root. + */ + private normalizePathToRelative(inputPath: string, projectRoot: string): string { + if (path.isAbsolute(inputPath)) { + return path.relative(projectRoot, inputPath) + } + return inputPath + } + + /** + * Filter files by path prefix. + */ + private filterByPath( + allFiles: Map, + targetPath: string, + ): Map { + const filtered = new Map() + + for (const [filePath, fileData] of allFiles) { + if (filePath === targetPath || filePath.startsWith(`${targetPath}/`)) { + filtered.set(filePath, fileData) + } + } + + return filtered + } + + /** + * Find all TODOs in a file. + */ + private findTodosInFile( + filePath: string, + lines: string[], + filterType: TodoType | null, + ): TodoEntry[] { + const todos: TodoEntry[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const match = TODO_PATTERN.exec(line) + + if (match) { + const type = match[1].toUpperCase() as TodoType + const text = match[2].trim() + + if (filterType && type !== filterType) { + continue + } + + todos.push({ + path: filePath, + line: i + 1, + type, + text: text || "(no description)", + context: line.trim(), + }) + } + } + + return todos + } + + /** + * Count TODOs by type. + */ + private countByType(todos: TodoEntry[]): Record { + const counts: Record = { + TODO: 0, + FIXME: 0, + HACK: 0, + XXX: 0, + BUG: 0, + NOTE: 0, + } + + for (const todo of todos) { + counts[todo.type]++ + } + + return counts + } + + /** + * Create empty result structure. + */ + private createEmptyResult(searchedPath: string | null): GetTodosResult { + return { + searchedPath, + totalTodos: 0, + filesWithTodos: 0, + byType: { + TODO: 0, + FIXME: 0, + HACK: 0, + XXX: 0, + BUG: 0, + NOTE: 0, + }, + todos: [], + } + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/analysis/index.ts b/packages/ipuaro/src/infrastructure/tools/analysis/index.ts new file mode 100644 index 0000000..43d8433 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/analysis/index.ts @@ -0,0 +1,20 @@ +// Analysis tools module exports +export { + GetDependenciesTool, + type GetDependenciesResult, + type DependencyEntry, +} from "./GetDependenciesTool.js" + +export { + GetDependentsTool, + type GetDependentsResult, + type DependentEntry, +} from "./GetDependentsTool.js" + +export { + GetComplexityTool, + type GetComplexityResult, + type ComplexityEntry, +} from "./GetComplexityTool.js" + +export { GetTodosTool, type GetTodosResult, type TodoEntry, type TodoType } from "./GetTodosTool.js" diff --git a/packages/ipuaro/src/infrastructure/tools/index.ts b/packages/ipuaro/src/infrastructure/tools/index.ts index c66d491..46d1c3c 100644 --- a/packages/ipuaro/src/infrastructure/tools/index.ts +++ b/packages/ipuaro/src/infrastructure/tools/index.ts @@ -27,3 +27,29 @@ export { type FindDefinitionResult, type DefinitionLocation, } from "./search/FindDefinitionTool.js" + +// Analysis tools +export { + GetDependenciesTool, + type GetDependenciesResult, + type DependencyEntry, +} from "./analysis/GetDependenciesTool.js" + +export { + GetDependentsTool, + type GetDependentsResult, + type DependentEntry, +} from "./analysis/GetDependentsTool.js" + +export { + GetComplexityTool, + type GetComplexityResult, + type ComplexityEntry, +} from "./analysis/GetComplexityTool.js" + +export { + GetTodosTool, + type GetTodosResult, + type TodoEntry, + type TodoType, +} from "./analysis/GetTodosTool.js" diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetComplexityTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetComplexityTool.test.ts new file mode 100644 index 0000000..41467c8 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetComplexityTool.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetComplexityTool, + type GetComplexityResult, +} from "../../../../../src/infrastructure/tools/analysis/GetComplexityTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import type { FileMeta } from "../../../../../src/domain/value-objects/FileMeta.js" + +function createMockFileMeta(partial: Partial = {}): FileMeta { + return { + complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 }, + dependencies: [], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + ...partial, + } +} + +function createMockStorage(metas: Map = new Map()): IStorage { + return { + getFile: vi.fn().mockResolvedValue(null), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn().mockResolvedValue(new Map()), + getFileCount: vi.fn().mockResolvedValue(0), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockImplementation((p: string) => Promise.resolve(metas.get(p) ?? null)), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn().mockResolvedValue(metas), + getSymbolIndex: vi.fn().mockResolvedValue(new Map()), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetComplexityTool", () => { + let tool: GetComplexityTool + + beforeEach(() => { + tool = new GetComplexityTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_complexity") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("analysis") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(2) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(false) + expect(tool.parameters[1].name).toBe("limit") + expect(tool.parameters[1].required).toBe(false) + }) + + it("should have description", () => { + expect(tool.description).toContain("complexity") + }) + }) + + describe("validateParams", () => { + it("should return null for no params", () => { + expect(tool.validateParams({})).toBeNull() + }) + + it("should return null for valid path", () => { + expect(tool.validateParams({ path: "src/index.ts" })).toBeNull() + }) + + it("should return null for valid limit", () => { + expect(tool.validateParams({ limit: 10 })).toBeNull() + }) + + it("should return null for valid path and limit", () => { + expect(tool.validateParams({ path: "src", limit: 5 })).toBeNull() + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123 })).toBe("Parameter 'path' must be a string") + }) + + it("should return error for non-integer limit", () => { + expect(tool.validateParams({ limit: 10.5 })).toBe( + "Parameter 'limit' must be an integer", + ) + }) + + it("should return error for non-number limit", () => { + expect(tool.validateParams({ limit: "10" })).toBe( + "Parameter 'limit' must be an integer", + ) + }) + + it("should return error for limit less than 1", () => { + expect(tool.validateParams({ limit: 0 })).toBe("Parameter 'limit' must be at least 1") + }) + + it("should return error for negative limit", () => { + expect(tool.validateParams({ limit: -5 })).toBe("Parameter 'limit' must be at least 1") + }) + }) + + describe("execute", () => { + it("should return complexity for all files without path", async () => { + const metas = new Map([ + [ + "src/a.ts", + createMockFileMeta({ + complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 50 }, + }), + ], + [ + "src/b.ts", + createMockFileMeta({ + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 25 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.analyzedPath).toBeNull() + expect(data.totalFiles).toBe(2) + expect(data.files).toHaveLength(2) + }) + + it("should sort files by complexity score descending", async () => { + const metas = new Map([ + [ + "src/low.ts", + createMockFileMeta({ + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 2, score: 10 }, + }), + ], + [ + "src/high.ts", + createMockFileMeta({ + complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 25, score: 80 }, + }), + ], + [ + "src/mid.ts", + createMockFileMeta({ + complexity: { loc: 50, nesting: 3, cyclomaticComplexity: 10, score: 40 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.files[0].path).toBe("src/high.ts") + expect(data.files[1].path).toBe("src/mid.ts") + expect(data.files[2].path).toBe("src/low.ts") + }) + + it("should filter by path prefix", async () => { + const metas = new Map([ + ["src/a.ts", createMockFileMeta()], + ["src/b.ts", createMockFileMeta()], + ["lib/c.ts", createMockFileMeta()], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.analyzedPath).toBe("src") + expect(data.totalFiles).toBe(2) + expect(data.files.every((f) => f.path.startsWith("src/"))).toBe(true) + }) + + it("should filter by specific file path", async () => { + const metas = new Map([ + [ + "src/a.ts", + createMockFileMeta({ + complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 15, score: 55 }, + }), + ], + ["src/b.ts", createMockFileMeta()], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/a.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.totalFiles).toBe(1) + expect(data.files[0].path).toBe("src/a.ts") + expect(data.files[0].metrics.score).toBe(55) + }) + + it("should respect limit parameter", async () => { + const metas = new Map([ + [ + "src/a.ts", + createMockFileMeta({ + complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 70 }, + }), + ], + [ + "src/b.ts", + createMockFileMeta({ + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 50 }, + }), + ], + [ + "src/c.ts", + createMockFileMeta({ + complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 2, score: 20 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ limit: 2 }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.totalFiles).toBe(3) + expect(data.files).toHaveLength(2) + expect(data.files[0].metrics.score).toBe(70) + expect(data.files[1].metrics.score).toBe(50) + }) + + it("should use default limit of 20", async () => { + const metas = new Map() + for (let i = 0; i < 30; i++) { + metas.set(`src/file${String(i)}.ts`, createMockFileMeta()) + } + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.totalFiles).toBe(30) + expect(data.files).toHaveLength(20) + }) + + it("should calculate average score", async () => { + const metas = new Map([ + [ + "src/a.ts", + createMockFileMeta({ + complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 60 }, + }), + ], + [ + "src/b.ts", + createMockFileMeta({ + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 40 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.averageScore).toBe(50) + }) + + it("should calculate summary statistics", async () => { + const metas = new Map([ + [ + "src/high.ts", + createMockFileMeta({ + complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 25, score: 75 }, + }), + ], + [ + "src/medium.ts", + createMockFileMeta({ + complexity: { loc: 80, nesting: 3, cyclomaticComplexity: 12, score: 45 }, + }), + ], + [ + "src/low.ts", + createMockFileMeta({ + complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 3, score: 15 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.summary.highComplexity).toBe(1) + expect(data.summary.mediumComplexity).toBe(1) + expect(data.summary.lowComplexity).toBe(1) + }) + + it("should return empty result for empty project", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.totalFiles).toBe(0) + expect(data.averageScore).toBe(0) + expect(data.files).toEqual([]) + expect(data.summary).toEqual({ + highComplexity: 0, + mediumComplexity: 0, + lowComplexity: 0, + }) + }) + + it("should return error for non-existent path", async () => { + const metas = new Map([["src/a.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "nonexistent" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("No files found at path") + }) + + it("should handle absolute paths", async () => { + const metas = new Map([ + ["src/a.ts", createMockFileMeta()], + ["src/b.ts", createMockFileMeta()], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "/test/project/src" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.analyzedPath).toBe("src") + expect(data.totalFiles).toBe(2) + }) + + it("should include file metadata", async () => { + const metas = new Map([ + [ + "src/hub.ts", + createMockFileMeta({ + fileType: "source", + isHub: true, + complexity: { loc: 150, nesting: 4, cyclomaticComplexity: 18, score: 65 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.files[0].fileType).toBe("source") + expect(data.files[0].isHub).toBe(true) + expect(data.files[0].metrics).toEqual({ + loc: 150, + nesting: 4, + cyclomaticComplexity: 18, + score: 65, + }) + }) + + it("should include callId in result", async () => { + const metas = new Map([["src/a.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.callId).toMatch(/^get_complexity-\d+$/) + }) + + it("should include execution time in result", async () => { + const metas = new Map([["src/a.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle storage errors gracefully", async () => { + const storage = createMockStorage() + ;(storage.getAllMetas as ReturnType).mockRejectedValue( + new Error("Redis connection failed"), + ) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Redis connection failed") + }) + + it("should round average score to 2 decimal places", async () => { + const metas = new Map([ + [ + "src/a.ts", + createMockFileMeta({ + complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 33 }, + }), + ], + [ + "src/b.ts", + createMockFileMeta({ + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 33 }, + }), + ], + [ + "src/c.ts", + createMockFileMeta({ + complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 2, score: 34 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.averageScore).toBe(33.33) + }) + + it("should handle complexity threshold boundaries", async () => { + const metas = new Map([ + [ + "src/exact-high.ts", + createMockFileMeta({ + complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 20, score: 60 }, + }), + ], + [ + "src/exact-medium.ts", + createMockFileMeta({ + complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 30 }, + }), + ], + [ + "src/below-medium.ts", + createMockFileMeta({ + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 29 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetComplexityResult + expect(data.summary.highComplexity).toBe(1) + expect(data.summary.mediumComplexity).toBe(1) + expect(data.summary.lowComplexity).toBe(1) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependenciesTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependenciesTool.test.ts new file mode 100644 index 0000000..b960787 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependenciesTool.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetDependenciesTool, + type GetDependenciesResult, +} from "../../../../../src/infrastructure/tools/analysis/GetDependenciesTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import type { FileMeta } from "../../../../../src/domain/value-objects/FileMeta.js" + +function createMockFileMeta(partial: Partial = {}): FileMeta { + return { + complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 }, + dependencies: [], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + ...partial, + } +} + +function createMockStorage(metas: Map = new Map()): IStorage { + return { + getFile: vi.fn().mockResolvedValue(null), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn().mockResolvedValue(new Map()), + getFileCount: vi.fn().mockResolvedValue(0), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockImplementation((p: string) => Promise.resolve(metas.get(p) ?? null)), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn().mockResolvedValue(metas), + getSymbolIndex: vi.fn().mockResolvedValue(new Map()), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetDependenciesTool", () => { + let tool: GetDependenciesTool + + beforeEach(() => { + tool = new GetDependenciesTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_dependencies") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("analysis") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(1) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(true) + }) + + it("should have description", () => { + expect(tool.description).toContain("imports") + }) + }) + + describe("validateParams", () => { + it("should return null for valid path", () => { + expect(tool.validateParams({ path: "src/index.ts" })).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({})).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for whitespace-only path", () => { + expect(tool.validateParams({ path: " " })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123 })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + }) + + describe("execute", () => { + it("should return dependencies for a file", async () => { + const metas = new Map([ + [ + "src/index.ts", + createMockFileMeta({ + dependencies: ["src/utils.ts", "src/config.ts"], + }), + ], + ["src/utils.ts", createMockFileMeta({ isHub: true })], + ["src/config.ts", createMockFileMeta({ isEntryPoint: true })], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.file).toBe("src/index.ts") + expect(data.totalDependencies).toBe(2) + expect(data.dependencies).toHaveLength(2) + }) + + it("should include metadata for each dependency", async () => { + const metas = new Map([ + [ + "src/index.ts", + createMockFileMeta({ + dependencies: ["src/utils.ts"], + }), + ], + [ + "src/utils.ts", + createMockFileMeta({ + isHub: true, + isEntryPoint: false, + fileType: "source", + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.dependencies[0]).toEqual({ + path: "src/utils.ts", + exists: true, + isEntryPoint: false, + isHub: true, + fileType: "source", + }) + }) + + it("should handle file with no dependencies", async () => { + const metas = new Map([ + ["src/standalone.ts", createMockFileMeta({ dependencies: [] })], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/standalone.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.totalDependencies).toBe(0) + expect(data.dependencies).toEqual([]) + }) + + it("should return error for non-existent file", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "nonexistent.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("File not found or not indexed") + }) + + it("should handle absolute paths", async () => { + const metas = new Map([ + ["src/index.ts", createMockFileMeta({ dependencies: [] })], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "/test/project/src/index.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.file).toBe("src/index.ts") + }) + + it("should mark non-existent dependencies", async () => { + const metas = new Map([ + [ + "src/index.ts", + createMockFileMeta({ + dependencies: ["src/missing.ts"], + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.dependencies[0].exists).toBe(false) + expect(data.dependencies[0].isHub).toBe(false) + expect(data.dependencies[0].fileType).toBe("unknown") + }) + + it("should sort dependencies by path", async () => { + const metas = new Map([ + [ + "src/index.ts", + createMockFileMeta({ + dependencies: ["src/z.ts", "src/a.ts", "src/m.ts"], + }), + ], + ["src/z.ts", createMockFileMeta()], + ["src/a.ts", createMockFileMeta()], + ["src/m.ts", createMockFileMeta()], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.dependencies[0].path).toBe("src/a.ts") + expect(data.dependencies[1].path).toBe("src/m.ts") + expect(data.dependencies[2].path).toBe("src/z.ts") + }) + + it("should include file type of source file", async () => { + const metas = new Map([ + [ + "tests/index.test.ts", + createMockFileMeta({ + fileType: "test", + dependencies: [], + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "tests/index.test.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.fileType).toBe("test") + }) + + it("should include callId in result", async () => { + const metas = new Map([["src/index.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.callId).toMatch(/^get_dependencies-\d+$/) + }) + + it("should include execution time in result", async () => { + const metas = new Map([["src/index.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle storage errors gracefully", async () => { + const storage = createMockStorage() + ;(storage.getMeta as ReturnType).mockRejectedValue( + new Error("Redis connection failed"), + ) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Redis connection failed") + }) + + it("should trim path before searching", async () => { + const metas = new Map([["src/index.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: " src/index.ts " }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.file).toBe("src/index.ts") + }) + + it("should handle many dependencies", async () => { + const deps = Array.from({ length: 50 }, (_, i) => `src/dep${String(i)}.ts`) + const metas = new Map([ + ["src/index.ts", createMockFileMeta({ dependencies: deps })], + ...deps.map((dep) => [dep, createMockFileMeta()] as [string, FileMeta]), + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/index.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependenciesResult + expect(data.totalDependencies).toBe(50) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependentsTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependentsTool.test.ts new file mode 100644 index 0000000..c2dc79e --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetDependentsTool.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetDependentsTool, + type GetDependentsResult, +} from "../../../../../src/infrastructure/tools/analysis/GetDependentsTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import type { FileMeta } from "../../../../../src/domain/value-objects/FileMeta.js" + +function createMockFileMeta(partial: Partial = {}): FileMeta { + return { + complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 }, + dependencies: [], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + ...partial, + } +} + +function createMockStorage(metas: Map = new Map()): IStorage { + return { + getFile: vi.fn().mockResolvedValue(null), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn().mockResolvedValue(new Map()), + getFileCount: vi.fn().mockResolvedValue(0), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockImplementation((p: string) => Promise.resolve(metas.get(p) ?? null)), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn().mockResolvedValue(metas), + getSymbolIndex: vi.fn().mockResolvedValue(new Map()), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetDependentsTool", () => { + let tool: GetDependentsTool + + beforeEach(() => { + tool = new GetDependentsTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_dependents") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("analysis") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(1) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(true) + }) + + it("should have description", () => { + expect(tool.description).toContain("import") + }) + }) + + describe("validateParams", () => { + it("should return null for valid path", () => { + expect(tool.validateParams({ path: "src/utils.ts" })).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({})).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for whitespace-only path", () => { + expect(tool.validateParams({ path: " " })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123 })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + }) + + describe("execute", () => { + it("should return dependents for a file", async () => { + const metas = new Map([ + [ + "src/utils.ts", + createMockFileMeta({ + dependents: ["src/index.ts", "src/app.ts"], + isHub: true, + }), + ], + ["src/index.ts", createMockFileMeta({ isEntryPoint: true })], + ["src/app.ts", createMockFileMeta()], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.file).toBe("src/utils.ts") + expect(data.totalDependents).toBe(2) + expect(data.isHub).toBe(true) + expect(data.dependents).toHaveLength(2) + }) + + it("should include metadata for each dependent", async () => { + const metas = new Map([ + [ + "src/utils.ts", + createMockFileMeta({ + dependents: ["src/index.ts"], + }), + ], + [ + "src/index.ts", + createMockFileMeta({ + isHub: false, + isEntryPoint: true, + fileType: "source", + complexity: { loc: 50, nesting: 3, cyclomaticComplexity: 10, score: 45 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.dependents[0]).toEqual({ + path: "src/index.ts", + isEntryPoint: true, + isHub: false, + fileType: "source", + complexityScore: 45, + }) + }) + + it("should handle file with no dependents", async () => { + const metas = new Map([ + ["src/isolated.ts", createMockFileMeta({ dependents: [] })], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/isolated.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.totalDependents).toBe(0) + expect(data.isHub).toBe(false) + expect(data.dependents).toEqual([]) + }) + + it("should return error for non-existent file", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "nonexistent.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("File not found or not indexed") + }) + + it("should handle absolute paths", async () => { + const metas = new Map([ + ["src/utils.ts", createMockFileMeta({ dependents: [] })], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "/test/project/src/utils.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.file).toBe("src/utils.ts") + }) + + it("should handle missing dependent metadata", async () => { + const metas = new Map([ + [ + "src/utils.ts", + createMockFileMeta({ + dependents: ["src/missing.ts"], + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.dependents[0].isHub).toBe(false) + expect(data.dependents[0].isEntryPoint).toBe(false) + expect(data.dependents[0].fileType).toBe("unknown") + expect(data.dependents[0].complexityScore).toBe(0) + }) + + it("should sort dependents by path", async () => { + const metas = new Map([ + [ + "src/utils.ts", + createMockFileMeta({ + dependents: ["src/z.ts", "src/a.ts", "src/m.ts"], + }), + ], + ["src/z.ts", createMockFileMeta()], + ["src/a.ts", createMockFileMeta()], + ["src/m.ts", createMockFileMeta()], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.dependents[0].path).toBe("src/a.ts") + expect(data.dependents[1].path).toBe("src/m.ts") + expect(data.dependents[2].path).toBe("src/z.ts") + }) + + it("should include file type of source file", async () => { + const metas = new Map([ + [ + "src/types.ts", + createMockFileMeta({ + fileType: "types", + dependents: [], + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/types.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.fileType).toBe("types") + }) + + it("should correctly identify hub files", async () => { + const dependents = Array.from({ length: 10 }, (_, i) => `src/file${String(i)}.ts`) + const metas = new Map([ + [ + "src/core.ts", + createMockFileMeta({ + dependents, + isHub: true, + }), + ], + ...dependents.map((dep) => [dep, createMockFileMeta()] as [string, FileMeta]), + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/core.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.isHub).toBe(true) + expect(data.totalDependents).toBe(10) + }) + + it("should include callId in result", async () => { + const metas = new Map([["src/utils.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.callId).toMatch(/^get_dependents-\d+$/) + }) + + it("should include execution time in result", async () => { + const metas = new Map([["src/utils.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle storage errors gracefully", async () => { + const storage = createMockStorage() + ;(storage.getMeta as ReturnType).mockRejectedValue( + new Error("Redis connection failed"), + ) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Redis connection failed") + }) + + it("should trim path before searching", async () => { + const metas = new Map([["src/utils.ts", createMockFileMeta()]]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: " src/utils.ts " }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + expect(data.file).toBe("src/utils.ts") + }) + + it("should include complexity scores for dependents", async () => { + const metas = new Map([ + [ + "src/utils.ts", + createMockFileMeta({ + dependents: ["src/high.ts", "src/low.ts"], + }), + ], + [ + "src/high.ts", + createMockFileMeta({ + complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 20, score: 80 }, + }), + ], + [ + "src/low.ts", + createMockFileMeta({ + complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 2, score: 10 }, + }), + ], + ]) + const storage = createMockStorage(metas) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/utils.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetDependentsResult + const highDep = data.dependents.find((d) => d.path === "src/high.ts") + const lowDep = data.dependents.find((d) => d.path === "src/low.ts") + expect(highDep?.complexityScore).toBe(80) + expect(lowDep?.complexityScore).toBe(10) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetTodosTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetTodosTool.test.ts new file mode 100644 index 0000000..8dcb642 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/analysis/GetTodosTool.test.ts @@ -0,0 +1,583 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + GetTodosTool, + type GetTodosResult, +} from "../../../../../src/infrastructure/tools/analysis/GetTodosTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import type { FileData } from "../../../../../src/domain/value-objects/FileData.js" + +function createMockFileData(lines: string[]): FileData { + return { + lines, + hash: "abc123", + size: lines.join("\n").length, + lastModified: Date.now(), + } +} + +function createMockStorage(files: Map = new Map()): IStorage { + return { + getFile: vi.fn().mockImplementation((p: string) => Promise.resolve(files.get(p) ?? null)), + setFile: vi.fn(), + deleteFile: vi.fn(), + getAllFiles: vi.fn().mockResolvedValue(files), + getFileCount: vi.fn().mockResolvedValue(files.size), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockResolvedValue(null), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn().mockResolvedValue(new Map()), + getSymbolIndex: vi.fn().mockResolvedValue(new Map()), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext(storage?: IStorage): ToolContext { + return { + projectRoot: "/test/project", + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(true), + onProgress: vi.fn(), + } +} + +describe("GetTodosTool", () => { + let tool: GetTodosTool + + beforeEach(() => { + tool = new GetTodosTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_todos") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("analysis") + }) + + it("should not require confirmation", () => { + expect(tool.requiresConfirmation).toBe(false) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(2) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(false) + expect(tool.parameters[1].name).toBe("type") + expect(tool.parameters[1].required).toBe(false) + }) + + it("should have description", () => { + expect(tool.description).toContain("TODO") + expect(tool.description).toContain("FIXME") + }) + }) + + describe("validateParams", () => { + it("should return null for no params", () => { + expect(tool.validateParams({})).toBeNull() + }) + + it("should return null for valid path", () => { + expect(tool.validateParams({ path: "src" })).toBeNull() + }) + + it("should return null for valid type", () => { + expect(tool.validateParams({ type: "TODO" })).toBeNull() + }) + + it("should return null for lowercase type", () => { + expect(tool.validateParams({ type: "fixme" })).toBeNull() + }) + + it("should return null for path and type", () => { + expect(tool.validateParams({ path: "src", type: "TODO" })).toBeNull() + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123 })).toBe("Parameter 'path' must be a string") + }) + + it("should return error for non-string type", () => { + expect(tool.validateParams({ type: 123 })).toBe("Parameter 'type' must be a string") + }) + + it("should return error for invalid type", () => { + expect(tool.validateParams({ type: "INVALID" })).toBe( + "Parameter 'type' must be one of: TODO, FIXME, HACK, XXX, BUG, NOTE", + ) + }) + }) + + describe("execute", () => { + it("should find TODO comments", async () => { + const files = new Map([ + [ + "src/index.ts", + createMockFileData([ + "// TODO: implement this", + "function foo() {}", + "// TODO: add tests", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(2) + expect(data.todos[0].type).toBe("TODO") + expect(data.todos[0].text).toBe("implement this") + expect(data.todos[1].text).toBe("add tests") + }) + + it("should find FIXME comments", async () => { + const files = new Map([ + [ + "src/index.ts", + createMockFileData(["// FIXME: broken logic here", "const x = 1"]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(1) + expect(data.todos[0].type).toBe("FIXME") + expect(data.todos[0].text).toBe("broken logic here") + }) + + it("should find HACK comments", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// HACK: temporary workaround"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].type).toBe("HACK") + }) + + it("should find XXX comments", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// XXX: needs attention"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].type).toBe("XXX") + }) + + it("should find BUG comments", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// BUG: race condition"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].type).toBe("BUG") + }) + + it("should find NOTE comments", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// NOTE: important consideration"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].type).toBe("NOTE") + }) + + it("should find comments in block comments", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["/*", " * TODO: in block comment", " */"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(1) + expect(data.todos[0].text).toBe("in block comment") + }) + + it("should find comments with author annotation", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// TODO(john): fix this"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].text).toBe("fix this") + }) + + it("should handle TODO without colon", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// TODO implement feature"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].text).toBe("implement feature") + }) + + it("should filter by type", async () => { + const files = new Map([ + [ + "src/index.ts", + createMockFileData([ + "// TODO: task one", + "// FIXME: bug here", + "// TODO: task two", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ type: "TODO" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(2) + expect(data.todos.every((t) => t.type === "TODO")).toBe(true) + }) + + it("should filter by type case-insensitively", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// TODO: task", "// FIXME: bug"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ type: "todo" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(1) + expect(data.todos[0].type).toBe("TODO") + }) + + it("should filter by path", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["// TODO: in src"])], + ["lib/b.ts", createMockFileData(["// TODO: in lib"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.searchedPath).toBe("src") + expect(data.totalTodos).toBe(1) + expect(data.todos[0].path).toBe("src/a.ts") + }) + + it("should filter by specific file", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["// TODO: in a"])], + ["src/b.ts", createMockFileData(["// TODO: in b"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "src/a.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(1) + expect(data.todos[0].path).toBe("src/a.ts") + }) + + it("should return error for non-existent path", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["// TODO: task"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "nonexistent" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toContain("No files found at path") + }) + + it("should count by type", async () => { + const files = new Map([ + [ + "src/index.ts", + createMockFileData([ + "// TODO: task 1", + "// TODO: task 2", + "// FIXME: bug", + "// HACK: workaround", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.byType.TODO).toBe(2) + expect(data.byType.FIXME).toBe(1) + expect(data.byType.HACK).toBe(1) + expect(data.byType.XXX).toBe(0) + expect(data.byType.BUG).toBe(0) + expect(data.byType.NOTE).toBe(0) + }) + + it("should count files with todos", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["// TODO: task"])], + ["src/b.ts", createMockFileData(["const x = 1"])], + ["src/c.ts", createMockFileData(["// TODO: another task"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.filesWithTodos).toBe(2) + }) + + it("should sort results by path then line", async () => { + const files = new Map([ + ["src/b.ts", createMockFileData(["// TODO: b1", "", "// TODO: b2"])], + ["src/a.ts", createMockFileData(["// TODO: a1"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].path).toBe("src/a.ts") + expect(data.todos[1].path).toBe("src/b.ts") + expect(data.todos[1].line).toBe(1) + expect(data.todos[2].path).toBe("src/b.ts") + expect(data.todos[2].line).toBe(3) + }) + + it("should include line context", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData([" // TODO: indented task"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].context).toBe("// TODO: indented task") + }) + + it("should return empty result for empty project", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(0) + expect(data.filesWithTodos).toBe(0) + expect(data.todos).toEqual([]) + }) + + it("should return empty result when no todos found", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["const x = 1", "const y = 2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(0) + }) + + it("should handle TODO without description", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// TODO:"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].text).toBe("(no description)") + }) + + it("should handle absolute paths", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["// TODO: task"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({ path: "/test/project/src" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.searchedPath).toBe("src") + }) + + it("should find todos with hash comments", async () => { + const files = new Map([ + ["script.sh", createMockFileData(["# TODO: shell script task"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(1) + expect(data.todos[0].text).toBe("shell script task") + }) + + it("should include callId in result", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.callId).toMatch(/^get_todos-\d+$/) + }) + + it("should include execution time in result", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle storage errors gracefully", async () => { + const storage = createMockStorage() + ;(storage.getAllFiles as ReturnType).mockRejectedValue( + new Error("Redis connection failed"), + ) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Redis connection failed") + }) + + it("should find lowercase todo markers", async () => { + const files = new Map([ + ["src/index.ts", createMockFileData(["// todo: lowercase"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(1) + expect(data.todos[0].type).toBe("TODO") + }) + + it("should handle multiple files with todos", async () => { + const files = new Map([ + ["src/a.ts", createMockFileData(["// TODO: a1", "// TODO: a2"])], + ["src/b.ts", createMockFileData(["// FIXME: b1"])], + ["src/c.ts", createMockFileData(["// HACK: c1", "// BUG: c2"])], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.totalTodos).toBe(5) + expect(data.filesWithTodos).toBe(3) + }) + + it("should correctly identify line numbers", async () => { + const files = new Map([ + [ + "src/index.ts", + createMockFileData([ + "const a = 1", + "const b = 2", + "// TODO: on line 3", + "const c = 3", + ]), + ], + ]) + const storage = createMockStorage(files) + const ctx = createMockContext(storage) + + const result = await tool.execute({}, ctx) + + expect(result.success).toBe(true) + const data = result.data as GetTodosResult + expect(data.todos[0].line).toBe(3) + }) + }) +})