Compare commits

..

3 Commits

Author SHA1 Message Date
imfozilbek
2ae1ac13f5 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%
2025-12-01 02:23:36 +05:00
imfozilbek
caf7aac116 feat(ipuaro): add search tools (v0.7.0) 2025-12-01 02:05:27 +05:00
imfozilbek
4ad5a209c4 feat(ipuaro): add edit tools (v0.6.0)
Add file editing capabilities:
- EditLinesTool: replace lines with hash conflict detection
- CreateFileTool: create files with directory auto-creation
- DeleteFileTool: delete files from filesystem and storage

Total: 664 tests, 97.77% coverage
2025-12-01 01:44:45 +05:00
33 changed files with 6804 additions and 5 deletions

View File

@@ -5,6 +5,112 @@ 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
- **FindReferencesTool (0.7.1)**
- `find_references(symbol, path?)`: Find all usages of a symbol across the codebase
- Word boundary matching with support for special characters (e.g., `$value`)
- Context lines around each reference (1 line before/after)
- Marks definition vs usage references
- Optional path filter for scoped searches
- Returns: path, line, column, context, isDefinition
- 37 unit tests
- **FindDefinitionTool (0.7.2)**
- `find_definition(symbol)`: Find where a symbol is defined
- Uses SymbolIndex for fast lookups
- Returns multiple definitions (for overloads/re-exports)
- Suggests similar symbols when not found (Levenshtein distance)
- Context lines around definition (2 lines before/after)
- Returns: path, line, type, context
- 32 unit tests
### Changed
- Total tests: 733 (was 664)
- Coverage: 97.71% lines, 91.84% branches
- Search tools category now fully implemented (2/2 tools)
---
## [0.6.0] - 2025-12-01 - Edit Tools
### Added
- **EditLinesTool (0.6.1)**
- `edit_lines(path, start, end, content)`: Replace lines in a file
- Hash conflict detection (prevents editing externally modified files)
- Confirmation required with diff preview
- Automatic storage update after edit
- 35 unit tests
- **CreateFileTool (0.6.2)**
- `create_file(path, content)`: Create new file with content
- Automatic directory creation if needed
- Path validation (must be within project root)
- Prevents overwriting existing files
- Confirmation required before creation
- 26 unit tests
- **DeleteFileTool (0.6.3)**
- `delete_file(path)`: Delete file from filesystem and storage
- Removes file data, AST, and meta from Redis
- Confirmation required with file content preview
- 20 unit tests
### Changed
- Total tests: 664 (was 540)
- Coverage: 97.71% lines, 91.89% branches
- Coverage thresholds: 95% lines/functions/statements, 90% branches
---
## [0.5.0] - 2025-12-01 - Read Tools
### Added

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/ipuaro",
"version": "0.5.0",
"version": "0.8.0",
"description": "Local AI agent for codebase operations with infinite context feeling",
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View File

@@ -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, unknown>): 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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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<string, FileMeta>,
targetPath: string,
): Map<string, FileMeta> {
const filtered = new Map<string, FileMeta>()
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
}
}

View File

@@ -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, unknown>): 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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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
}
}

View File

@@ -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, unknown>): 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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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
}
}

View File

@@ -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<TodoType, number>
/** 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, unknown>): 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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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<string>()
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<string, FileData>,
targetPath: string,
): Map<string, FileData> {
const filtered = new Map<string, FileData>()
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<TodoType, number> {
const counts: Record<TodoType, number> = {
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: [],
}
}
}

View File

@@ -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"

View File

@@ -0,0 +1,140 @@
import { promises as fs } from "node:fs"
import * as path from "node:path"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import { createFileData } from "../../../domain/value-objects/FileData.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
import { hashLines } from "../../../shared/utils/hash.js"
/**
* Result data from create_file tool.
*/
export interface CreateFileResult {
path: string
lines: number
size: number
}
/**
* Tool for creating new files.
* Creates a new file with the specified content.
* Requires user confirmation before creating.
*/
export class CreateFileTool implements ITool {
readonly name = "create_file"
readonly description =
"Create a new file with the specified content. " +
"The file path must be within the project root. " +
"Requires confirmation before creating."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "File path relative to project root",
required: true,
},
{
name: "content",
type: "string",
description: "File content",
required: true,
},
]
readonly requiresConfirmation = true
readonly category = "edit" as const
validateParams(params: Record<string, unknown>): string | null {
if (typeof params.path !== "string" || params.path.trim() === "") {
return "Parameter 'path' is required and must be a non-empty string"
}
if (typeof params.content !== "string") {
return "Parameter 'content' is required and must be a string"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const relativePath = params.path as string
const content = params.content as string
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
if (!absolutePath.startsWith(ctx.projectRoot)) {
return createErrorResult(
callId,
"Path must be within project root",
Date.now() - startTime,
)
}
try {
const exists = await this.fileExists(absolutePath)
if (exists) {
return createErrorResult(
callId,
`File already exists: ${relativePath}`,
Date.now() - startTime,
)
}
const lines = content.split("\n")
const confirmed = await ctx.requestConfirmation(
`Create new file: ${relativePath} (${String(lines.length)} lines)`,
{
filePath: relativePath,
oldLines: [],
newLines: lines,
startLine: 1,
},
)
if (!confirmed) {
return createErrorResult(
callId,
"File creation cancelled by user",
Date.now() - startTime,
)
}
const dirPath = path.dirname(absolutePath)
await fs.mkdir(dirPath, { recursive: true })
await fs.writeFile(absolutePath, content, "utf-8")
const stats = await fs.stat(absolutePath)
const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs)
await ctx.storage.setFile(relativePath, fileData)
const result: CreateFileResult = {
path: relativePath,
lines: lines.length,
size: stats.size,
}
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)
}
}
/**
* Check if file exists.
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
}

View File

@@ -0,0 +1,136 @@
import { promises as fs } from "node:fs"
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"
/**
* Result data from delete_file tool.
*/
export interface DeleteFileResult {
path: string
deleted: boolean
}
/**
* Tool for deleting files.
* Deletes a file from the filesystem and storage.
* Requires user confirmation before deleting.
*/
export class DeleteFileTool implements ITool {
readonly name = "delete_file"
readonly description =
"Delete a file from the project. " +
"The file path must be within the project root. " +
"Requires confirmation before deleting."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "File path relative to project root",
required: true,
},
]
readonly requiresConfirmation = true
readonly category = "edit" as const
validateParams(params: Record<string, unknown>): 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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const relativePath = params.path as string
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
if (!absolutePath.startsWith(ctx.projectRoot)) {
return createErrorResult(
callId,
"Path must be within project root",
Date.now() - startTime,
)
}
try {
const exists = await this.fileExists(absolutePath)
if (!exists) {
return createErrorResult(
callId,
`File not found: ${relativePath}`,
Date.now() - startTime,
)
}
const fileContent = await this.getFileContent(absolutePath, relativePath, ctx)
const confirmed = await ctx.requestConfirmation(`Delete file: ${relativePath}`, {
filePath: relativePath,
oldLines: fileContent,
newLines: [],
startLine: 1,
})
if (!confirmed) {
return createErrorResult(
callId,
"File deletion cancelled by user",
Date.now() - startTime,
)
}
await fs.unlink(absolutePath)
await ctx.storage.deleteFile(relativePath)
await ctx.storage.deleteAST(relativePath)
await ctx.storage.deleteMeta(relativePath)
const result: DeleteFileResult = {
path: relativePath,
deleted: true,
}
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)
}
}
/**
* Check if file exists.
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
const stats = await fs.stat(filePath)
return stats.isFile()
} catch {
return false
}
}
/**
* Get file content for diff display.
*/
private async getFileContent(
absolutePath: string,
relativePath: string,
ctx: ToolContext,
): Promise<string[]> {
const fileData = await ctx.storage.getFile(relativePath)
if (fileData) {
return fileData.lines
}
const content = await fs.readFile(absolutePath, "utf-8")
return content.split("\n")
}
}

View File

@@ -0,0 +1,226 @@
import { promises as fs } from "node:fs"
import * as path from "node:path"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import { createFileData } from "../../../domain/value-objects/FileData.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
import { hashLines } from "../../../shared/utils/hash.js"
/**
* Result data from edit_lines tool.
*/
export interface EditLinesResult {
path: string
startLine: number
endLine: number
linesReplaced: number
linesInserted: number
totalLines: number
}
/**
* Tool for editing specific lines in a file.
* Replaces lines from start to end with new content.
* Requires user confirmation before applying changes.
*/
export class EditLinesTool implements ITool {
readonly name = "edit_lines"
readonly description =
"Replace lines in a file. Replaces lines from start to end (inclusive) with new content. " +
"Requires confirmation before applying changes."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "File path relative to project root",
required: true,
},
{
name: "start",
type: "number",
description: "Start line number (1-based, inclusive)",
required: true,
},
{
name: "end",
type: "number",
description: "End line number (1-based, inclusive)",
required: true,
},
{
name: "content",
type: "string",
description: "New content to insert (can be multi-line)",
required: true,
},
]
readonly requiresConfirmation = true
readonly category = "edit" as const
validateParams(params: Record<string, unknown>): string | null {
if (typeof params.path !== "string" || params.path.trim() === "") {
return "Parameter 'path' is required and must be a non-empty string"
}
if (typeof params.start !== "number" || !Number.isInteger(params.start)) {
return "Parameter 'start' is required and must be an integer"
}
if (params.start < 1) {
return "Parameter 'start' must be >= 1"
}
if (typeof params.end !== "number" || !Number.isInteger(params.end)) {
return "Parameter 'end' is required and must be an integer"
}
if (params.end < 1) {
return "Parameter 'end' must be >= 1"
}
if (params.start > params.end) {
return "Parameter 'start' must be <= 'end'"
}
if (typeof params.content !== "string") {
return "Parameter 'content' is required and must be a string"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const relativePath = params.path as string
const startLine = params.start as number
const endLine = params.end as number
const newContent = params.content as string
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
if (!absolutePath.startsWith(ctx.projectRoot)) {
return createErrorResult(
callId,
"Path must be within project root",
Date.now() - startTime,
)
}
try {
const currentLines = await this.getCurrentLines(absolutePath, relativePath, ctx)
const totalLines = currentLines.length
if (startLine > totalLines) {
return createErrorResult(
callId,
`Start line ${String(startLine)} exceeds file length (${String(totalLines)} lines)`,
Date.now() - startTime,
)
}
const adjustedEnd = Math.min(endLine, totalLines)
const conflictCheck = await this.checkHashConflict(relativePath, currentLines, ctx)
if (conflictCheck) {
return createErrorResult(callId, conflictCheck, Date.now() - startTime)
}
const oldLines = currentLines.slice(startLine - 1, adjustedEnd)
const newLines = newContent.split("\n")
const confirmed = await ctx.requestConfirmation(
`Replace lines ${String(startLine)}-${String(adjustedEnd)} in ${relativePath}`,
{
filePath: relativePath,
oldLines,
newLines,
startLine,
},
)
if (!confirmed) {
return createErrorResult(callId, "Edit cancelled by user", Date.now() - startTime)
}
const updatedLines = [
...currentLines.slice(0, startLine - 1),
...newLines,
...currentLines.slice(adjustedEnd),
]
await this.applyChanges(absolutePath, relativePath, updatedLines, ctx)
const result: EditLinesResult = {
path: relativePath,
startLine,
endLine: adjustedEnd,
linesReplaced: adjustedEnd - startLine + 1,
linesInserted: newLines.length,
totalLines: updatedLines.length,
}
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)
}
}
/**
* Get current file lines from storage or filesystem.
*/
private async getCurrentLines(
absolutePath: string,
relativePath: string,
ctx: ToolContext,
): Promise<string[]> {
const fileData = await ctx.storage.getFile(relativePath)
if (fileData) {
return fileData.lines
}
const content = await fs.readFile(absolutePath, "utf-8")
return content.split("\n")
}
/**
* Check if file has changed since it was indexed.
* Returns error message if conflict detected, null otherwise.
*/
private async checkHashConflict(
relativePath: string,
currentLines: string[],
ctx: ToolContext,
): Promise<string | null> {
const storedFile = await ctx.storage.getFile(relativePath)
if (!storedFile) {
return null
}
const currentHash = hashLines(currentLines)
if (storedFile.hash !== currentHash) {
return "File has been modified externally. Please refresh the file before editing."
}
return null
}
/**
* Apply changes to filesystem and storage.
*/
private async applyChanges(
absolutePath: string,
relativePath: string,
lines: string[],
ctx: ToolContext,
): Promise<void> {
const content = lines.join("\n")
await fs.writeFile(absolutePath, content, "utf-8")
const stats = await fs.stat(absolutePath)
const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs)
await ctx.storage.setFile(relativePath, fileData)
}
}

View File

@@ -0,0 +1,4 @@
// Edit tools exports
export { EditLinesTool, type EditLinesResult } from "./EditLinesTool.js"
export { CreateFileTool, type CreateFileResult } from "./CreateFileTool.js"
export { DeleteFileTool, type DeleteFileResult } from "./DeleteFileTool.js"

View File

@@ -10,3 +10,46 @@ export {
type GetStructureResult,
type TreeNode,
} from "./read/GetStructureTool.js"
// Edit tools
export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js"
export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js"
export { DeleteFileTool, type DeleteFileResult } from "./edit/DeleteFileTool.js"
// Search tools
export {
FindReferencesTool,
type FindReferencesResult,
type SymbolReference,
} from "./search/FindReferencesTool.js"
export {
FindDefinitionTool,
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"

View File

@@ -0,0 +1,221 @@
import { promises as fs } from "node:fs"
import * as path from "node:path"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import type { SymbolLocation } from "../../../domain/services/IStorage.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* A single definition location with context.
*/
export interface DefinitionLocation {
path: string
line: number
type: SymbolLocation["type"]
context: string
}
/**
* Result data from find_definition tool.
*/
export interface FindDefinitionResult {
symbol: string
found: boolean
definitions: DefinitionLocation[]
suggestions?: string[]
}
/**
* Tool for finding where a symbol is defined.
* Uses the SymbolIndex to locate definitions.
*/
export class FindDefinitionTool implements ITool {
readonly name = "find_definition"
readonly description =
"Find where a symbol is defined. " + "Returns file path, line number, and symbol type."
readonly parameters: ToolParameterSchema[] = [
{
name: "symbol",
type: "string",
description: "Symbol name to find definition for",
required: true,
},
]
readonly requiresConfirmation = false
readonly category = "search" as const
private readonly contextLines = 2
validateParams(params: Record<string, unknown>): string | null {
if (typeof params.symbol !== "string" || params.symbol.trim() === "") {
return "Parameter 'symbol' is required and must be a non-empty string"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const symbol = (params.symbol as string).trim()
try {
const symbolIndex = await ctx.storage.getSymbolIndex()
const locations = symbolIndex.get(symbol)
if (!locations || locations.length === 0) {
const suggestions = this.findSimilarSymbols(symbol, symbolIndex)
return createSuccessResult(
callId,
{
symbol,
found: false,
definitions: [],
suggestions: suggestions.length > 0 ? suggestions : undefined,
} satisfies FindDefinitionResult,
Date.now() - startTime,
)
}
const definitions: DefinitionLocation[] = []
for (const loc of locations) {
const context = await this.getContext(loc, ctx)
definitions.push({
path: loc.path,
line: loc.line,
type: loc.type,
context,
})
}
definitions.sort((a, b) => {
const pathCompare = a.path.localeCompare(b.path)
if (pathCompare !== 0) {
return pathCompare
}
return a.line - b.line
})
const result: FindDefinitionResult = {
symbol,
found: true,
definitions,
}
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)
}
}
/**
* Get context lines around the definition.
*/
private async getContext(loc: SymbolLocation, ctx: ToolContext): Promise<string> {
try {
const lines = await this.getFileLines(loc.path, ctx)
if (lines.length === 0) {
return ""
}
const lineIndex = loc.line - 1
const startIndex = Math.max(0, lineIndex - this.contextLines)
const endIndex = Math.min(lines.length - 1, lineIndex + this.contextLines)
const contextLines: string[] = []
for (let i = startIndex; i <= endIndex; i++) {
const lineNum = i + 1
const prefix = i === lineIndex ? ">" : " "
contextLines.push(`${prefix}${String(lineNum).padStart(4)}${lines[i]}`)
}
return contextLines.join("\n")
} catch {
return ""
}
}
/**
* Get file lines from storage or filesystem.
*/
private async getFileLines(relativePath: string, ctx: ToolContext): Promise<string[]> {
const fileData = await ctx.storage.getFile(relativePath)
if (fileData) {
return fileData.lines
}
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
try {
const content = await fs.readFile(absolutePath, "utf-8")
return content.split("\n")
} catch {
return []
}
}
/**
* Find similar symbol names for suggestions.
*/
private findSimilarSymbols(symbol: string, symbolIndex: Map<string, unknown>): string[] {
const suggestions: string[] = []
const lowerSymbol = symbol.toLowerCase()
const maxSuggestions = 5
for (const name of symbolIndex.keys()) {
if (suggestions.length >= maxSuggestions) {
break
}
const lowerName = name.toLowerCase()
if (lowerName.includes(lowerSymbol) || lowerSymbol.includes(lowerName)) {
suggestions.push(name)
} else if (this.levenshteinDistance(lowerSymbol, lowerName) <= 2) {
suggestions.push(name)
}
}
return suggestions.sort()
}
/**
* Calculate Levenshtein distance between two strings.
*/
private levenshteinDistance(a: string, b: string): number {
if (a.length === 0) {
return b.length
}
if (b.length === 0) {
return a.length
}
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1,
)
}
}
}
return matrix[b.length][a.length]
}
}

View File

@@ -0,0 +1,260 @@
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"
/**
* A single reference to a symbol.
*/
export interface SymbolReference {
path: string
line: number
column: number
context: string
isDefinition: boolean
}
/**
* Result data from find_references tool.
*/
export interface FindReferencesResult {
symbol: string
totalReferences: number
files: number
references: SymbolReference[]
definitionLocations: {
path: string
line: number
type: string
}[]
}
/**
* Tool for finding all usages of a symbol across the codebase.
* Searches through indexed files for symbol references.
*/
export class FindReferencesTool implements ITool {
readonly name = "find_references"
readonly description =
"Find all usages of a symbol across the codebase. " +
"Returns list of file paths, line numbers, and context."
readonly parameters: ToolParameterSchema[] = [
{
name: "symbol",
type: "string",
description: "Symbol name to search for (function, class, variable, etc.)",
required: true,
},
{
name: "path",
type: "string",
description: "Limit search to specific file or directory",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "search" as const
private readonly contextLines = 1
validateParams(params: Record<string, unknown>): string | null {
if (typeof params.symbol !== "string" || params.symbol.trim() === "") {
return "Parameter 'symbol' is required and must be a non-empty string"
}
if (params.path !== undefined && typeof params.path !== "string") {
return "Parameter 'path' must be a string"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const symbol = (params.symbol as string).trim()
const filterPath = params.path as string | undefined
try {
const symbolIndex = await ctx.storage.getSymbolIndex()
const definitionLocations = symbolIndex.get(symbol) ?? []
const allFiles = await ctx.storage.getAllFiles()
const filesToSearch = this.filterFiles(allFiles, filterPath, ctx.projectRoot)
if (filesToSearch.size === 0) {
return createSuccessResult(
callId,
{
symbol,
totalReferences: 0,
files: 0,
references: [],
definitionLocations: definitionLocations.map((loc) => ({
path: loc.path,
line: loc.line,
type: loc.type,
})),
} satisfies FindReferencesResult,
Date.now() - startTime,
)
}
const references: SymbolReference[] = []
const filesWithReferences = new Set<string>()
for (const [filePath, fileData] of filesToSearch) {
const fileRefs = this.findReferencesInFile(
filePath,
fileData.lines,
symbol,
definitionLocations,
)
if (fileRefs.length > 0) {
filesWithReferences.add(filePath)
references.push(...fileRefs)
}
}
references.sort((a, b) => {
const pathCompare = a.path.localeCompare(b.path)
if (pathCompare !== 0) {
return pathCompare
}
return a.line - b.line
})
const result: FindReferencesResult = {
symbol,
totalReferences: references.length,
files: filesWithReferences.size,
references,
definitionLocations: definitionLocations.map((loc) => ({
path: loc.path,
line: loc.line,
type: loc.type,
})),
}
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)
}
}
/**
* Filter files by path prefix if specified.
*/
private filterFiles(
allFiles: Map<string, { lines: string[] }>,
filterPath: string | undefined,
projectRoot: string,
): Map<string, { lines: string[] }> {
if (!filterPath) {
return allFiles
}
const normalizedFilter = filterPath.startsWith("/")
? path.relative(projectRoot, filterPath)
: filterPath
const filtered = new Map<string, { lines: string[] }>()
for (const [filePath, fileData] of allFiles) {
if (filePath === normalizedFilter || filePath.startsWith(`${normalizedFilter}/`)) {
filtered.set(filePath, fileData)
}
}
return filtered
}
/**
* Find all references to the symbol in a file.
*/
private findReferencesInFile(
filePath: string,
lines: string[],
symbol: string,
definitionLocations: { path: string; line: number }[],
): SymbolReference[] {
const references: SymbolReference[] = []
const symbolRegex = this.createSymbolRegex(symbol)
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex]
const lineNumber = lineIndex + 1
let match: RegExpExecArray | null
symbolRegex.lastIndex = 0
while ((match = symbolRegex.exec(line)) !== null) {
const column = match.index + 1
const context = this.buildContext(lines, lineIndex)
const isDefinition = this.isDefinitionLine(
filePath,
lineNumber,
definitionLocations,
)
references.push({
path: filePath,
line: lineNumber,
column,
context,
isDefinition,
})
}
}
return references
}
/**
* Create a regex for matching the symbol with appropriate boundaries.
* Handles symbols that start or end with non-word characters (like $value).
*/
private createSymbolRegex(symbol: string): RegExp {
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const startsWithWordChar = /^\w/.test(symbol)
const endsWithWordChar = /\w$/.test(symbol)
const prefix = startsWithWordChar ? "\\b" : "(?<![\\w$])"
const suffix = endsWithWordChar ? "\\b" : "(?![\\w$])"
return new RegExp(`${prefix}${escaped}${suffix}`, "g")
}
/**
* Build context string with surrounding lines.
*/
private buildContext(lines: string[], currentIndex: number): string {
const startIndex = Math.max(0, currentIndex - this.contextLines)
const endIndex = Math.min(lines.length - 1, currentIndex + this.contextLines)
const contextLines: string[] = []
for (let i = startIndex; i <= endIndex; i++) {
const lineNum = i + 1
const prefix = i === currentIndex ? ">" : " "
contextLines.push(`${prefix}${String(lineNum).padStart(4)}${lines[i]}`)
}
return contextLines.join("\n")
}
/**
* Check if this line is a definition location.
*/
private isDefinitionLine(
filePath: string,
lineNumber: number,
definitionLocations: { path: string; line: number }[],
): boolean {
return definitionLocations.some((loc) => loc.path === filePath && loc.line === lineNumber)
}
}

View File

@@ -0,0 +1,12 @@
// Search tools exports
export {
FindReferencesTool,
type FindReferencesResult,
type SymbolReference,
} from "./FindReferencesTool.js"
export {
FindDefinitionTool,
type FindDefinitionResult,
type DefinitionLocation,
} from "./FindDefinitionTool.js"

View File

@@ -63,6 +63,13 @@ describe("ChatMessage", () => {
expect(msg.content).toContain("[2] Error: Not found")
})
it("should handle error result without error message", () => {
const results = [{ callId: "3", success: false, executionTimeMs: 5 }]
const msg = createToolMessage(results)
expect(msg.content).toContain("[3] Error: Unknown error")
})
})
describe("createSystemMessage", () => {

View File

@@ -301,6 +301,66 @@ describe("ASTParser", () => {
})
})
describe("import string formats", () => {
it("should handle single-quoted imports", () => {
const code = `import { foo } from './module'`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].from).toBe("./module")
})
it("should handle double-quoted imports", () => {
const code = `import { bar } from "./other"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].from).toBe("./other")
})
})
describe("parameter types", () => {
it("should handle simple identifier parameters", () => {
const code = `const fn = (x) => x * 2`
const ast = parser.parse(code, "ts")
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
})
it("should handle optional parameters with defaults", () => {
const code = `function greet(name: string = "World"): string { return name }`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
const fn = ast.functions[0]
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
})
it("should handle arrow function with untyped params", () => {
const code = `const add = (a, b) => a + b`
const ast = parser.parse(code, "ts")
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
})
it("should handle multiple parameter types", () => {
const code = `
function mix(
required: string,
optional?: number,
withDefault: boolean = true
) {}
`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
const fn = ast.functions[0]
expect(fn.params).toHaveLength(3)
expect(fn.params.some((p) => p.optional)).toBe(true)
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
})
})
describe("complex file", () => {
it("should parse complex TypeScript file", () => {
const code = `

View File

@@ -212,6 +212,32 @@ describe("FileScanner", () => {
})
})
describe("empty file handling", () => {
it("should consider empty files as text files", async () => {
const emptyFile = path.join(FIXTURES_DIR, "empty-file.ts")
await fs.writeFile(emptyFile, "")
try {
const isText = await FileScanner.isTextFile(emptyFile)
expect(isText).toBe(true)
} finally {
await fs.unlink(emptyFile)
}
})
it("should read empty file content", async () => {
const emptyFile = path.join(FIXTURES_DIR, "empty-content.ts")
await fs.writeFile(emptyFile, "")
try {
const content = await FileScanner.readFileContent(emptyFile)
expect(content).toBe("")
} finally {
await fs.unlink(emptyFile)
}
})
})
describe("empty directory handling", () => {
let emptyDir: string

View File

@@ -605,4 +605,44 @@ export type ServiceResult<T> = { success: true; data: T } | { success: false; er
)
})
})
describe("jsx to tsx resolution", () => {
it("should resolve .jsx imports to .tsx files", () => {
const mainCode = `import { Button } from "./Button.jsx"`
const buttonCode = `export function Button() { return null }`
const asts = new Map<string, FileAST>([
["/project/src/main.ts", parser.parse(mainCode, "ts")],
["/project/src/Button.tsx", parser.parse(buttonCode, "tsx")],
])
const graph = builder.buildDepsGraph(asts)
expect(graph.imports.get("/project/src/main.ts")).toContain("/project/src/Button.tsx")
})
})
describe("edge cases", () => {
it("should handle empty deps graph for circular dependencies", () => {
const graph = {
imports: new Map<string, string[]>(),
importedBy: new Map<string, string[]>(),
}
const cycles = builder.findCircularDependencies(graph)
expect(cycles).toEqual([])
})
it("should handle single file with no imports", () => {
const code = `export const x = 1`
const asts = new Map<string, FileAST>([
["/project/src/single.ts", parser.parse(code, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const cycles = builder.findCircularDependencies(graph)
expect(cycles).toEqual([])
})
})
})

View File

@@ -544,6 +544,44 @@ const b = 2`
})
})
describe("dependency resolution with different extensions", () => {
it("should resolve imports from index files", () => {
const content = `import { utils } from "./utils/index"`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/main.ts", ast)
allASTs.set("/project/src/utils/index.ts", createEmptyFileAST())
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
expect(meta.dependencies).toContain("/project/src/utils/index.ts")
})
it("should convert .js extension to .ts when resolving", () => {
const content = `import { helper } from "./helper.js"`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/main.ts", ast)
allASTs.set("/project/src/helper.ts", createEmptyFileAST())
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
expect(meta.dependencies).toContain("/project/src/helper.ts")
})
it("should convert .jsx extension to .tsx when resolving", () => {
const content = `import { Button } from "./Button.jsx"`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/App.tsx", ast)
allASTs.set("/project/src/Button.tsx", createEmptyFileAST())
const meta = analyzer.analyze("/project/src/App.tsx", ast, content, allASTs)
expect(meta.dependencies).toContain("/project/src/Button.tsx")
})
})
describe("analyze", () => {
it("should produce complete FileMeta", () => {
const content = `import { helper } from "./helper"

View File

@@ -94,12 +94,70 @@ describe("Watchdog", () => {
it("should return empty array when not watching", () => {
expect(watchdog.getWatchedPaths()).toEqual([])
})
it("should return paths when watching", async () => {
const testFile = path.join(tempDir, "exists.ts")
await fs.writeFile(testFile, "const x = 1")
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 200))
const paths = watchdog.getWatchedPaths()
expect(Array.isArray(paths)).toBe(true)
})
})
describe("flushAll", () => {
it("should not throw when no pending changes", () => {
expect(() => watchdog.flushAll()).not.toThrow()
})
it("should flush all pending changes", async () => {
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const testFile = path.join(tempDir, "flush-test.ts")
await fs.writeFile(testFile, "const x = 1")
await new Promise((resolve) => setTimeout(resolve, 20))
watchdog.flushAll()
await new Promise((resolve) => setTimeout(resolve, 50))
})
})
describe("ignore patterns", () => {
it("should handle glob patterns with wildcards", async () => {
const customWatchdog = new Watchdog({
debounceMs: 50,
ignorePatterns: ["*.log", "**/*.tmp"],
})
customWatchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(customWatchdog.isWatching()).toBe(true)
await customWatchdog.stop()
})
it("should handle simple directory patterns", async () => {
const customWatchdog = new Watchdog({
debounceMs: 50,
ignorePatterns: ["node_modules", "dist"],
})
customWatchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(customWatchdog.isWatching()).toBe(true)
await customWatchdog.stop()
})
})
describe("file change detection", () => {

View File

@@ -301,4 +301,188 @@ describe("OllamaClient", () => {
expect(() => client.abort()).not.toThrow()
})
})
describe("message conversion", () => {
it("should convert system messages", async () => {
const client = new OllamaClient(defaultConfig)
const messages = [
{
role: "system" as const,
content: "You are a helpful assistant",
timestamp: Date.now(),
},
]
await client.chat(messages)
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "system",
content: "You are a helpful assistant",
}),
]),
}),
)
})
it("should convert tool result messages", async () => {
const client = new OllamaClient(defaultConfig)
const messages = [
{
role: "tool" as const,
content: '{"result": "success"}',
timestamp: Date.now(),
toolResults: [
{ callId: "call_1", success: true, data: "success", executionTimeMs: 10 },
],
},
]
await client.chat(messages)
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "tool",
content: '{"result": "success"}',
}),
]),
}),
)
})
it("should convert assistant messages with tool calls", async () => {
const client = new OllamaClient(defaultConfig)
const messages = [
{
role: "assistant" as const,
content: "I will read the file",
timestamp: Date.now(),
toolCalls: [{ id: "call_1", name: "get_lines", params: { path: "test.ts" } }],
},
]
await client.chat(messages)
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: "I will read the file",
tool_calls: expect.arrayContaining([
expect.objectContaining({
function: expect.objectContaining({
name: "get_lines",
arguments: { path: "test.ts" },
}),
}),
]),
}),
]),
}),
)
})
})
describe("response handling", () => {
it("should estimate tokens when eval_count is undefined", async () => {
mockOllamaInstance.chat.mockResolvedValue({
message: {
role: "assistant",
content: "Hello world response",
tool_calls: undefined,
},
eval_count: undefined,
done_reason: "stop",
})
const client = new OllamaClient(defaultConfig)
const response = await client.chat([createUserMessage("Hello")])
expect(response.tokens).toBeGreaterThan(0)
})
it("should return length stop reason", async () => {
mockOllamaInstance.chat.mockResolvedValue({
message: {
role: "assistant",
content: "Truncated...",
tool_calls: undefined,
},
eval_count: 100,
done_reason: "length",
})
const client = new OllamaClient(defaultConfig)
const response = await client.chat([createUserMessage("Hello")])
expect(response.stopReason).toBe("length")
})
})
describe("tool parameter conversion", () => {
it("should include enum values when present", async () => {
const client = new OllamaClient(defaultConfig)
const messages = [createUserMessage("Get status")]
const tools = [
{
name: "get_status",
description: "Get status",
parameters: [
{
name: "type",
type: "string" as const,
description: "Status type",
required: true,
enum: ["active", "inactive", "pending"],
},
],
},
]
await client.chat(messages, tools)
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
function: expect.objectContaining({
parameters: expect.objectContaining({
properties: expect.objectContaining({
type: expect.objectContaining({
enum: ["active", "inactive", "pending"],
}),
}),
}),
}),
}),
]),
}),
)
})
})
describe("error handling", () => {
it("should handle ECONNREFUSED errors", async () => {
mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED"))
const client = new OllamaClient(defaultConfig)
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(
/Cannot connect to Ollama/,
)
})
it("should handle generic errors with context", async () => {
mockOllamaInstance.pull.mockRejectedValue(new Error("Unknown error"))
const client = new OllamaClient(defaultConfig)
await expect(client.pullModel("test")).rejects.toThrow(/Failed to pull model/)
})
})
})

View File

@@ -249,6 +249,445 @@ describe("prompts", () => {
})
})
describe("buildFileContext - edge cases", () => {
it("should handle empty imports", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("empty.ts", ast)
expect(context).toContain("## empty.ts")
expect(context).not.toContain("### Imports")
})
it("should handle empty exports", () => {
const ast: FileAST = {
imports: [{ name: "x", from: "./x", line: 1, type: "internal", isDefault: false }],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("no-exports.ts", ast)
expect(context).toContain("### Imports")
expect(context).not.toContain("### Exports")
})
it("should handle empty functions", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [],
classes: [
{
name: "MyClass",
lineStart: 1,
lineEnd: 10,
methods: [],
properties: [],
implements: [],
isExported: false,
isAbstract: false,
},
],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("no-functions.ts", ast)
expect(context).not.toContain("### Functions")
expect(context).toContain("### Classes")
})
it("should handle empty classes", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [
{
name: "test",
lineStart: 1,
lineEnd: 5,
params: [],
isAsync: false,
isExported: false,
},
],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("no-classes.ts", ast)
expect(context).toContain("### Functions")
expect(context).not.toContain("### Classes")
})
it("should handle class without extends", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [],
classes: [
{
name: "Standalone",
lineStart: 1,
lineEnd: 10,
methods: [],
properties: [],
implements: ["IFoo"],
isExported: false,
isAbstract: false,
},
],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("standalone.ts", ast)
expect(context).toContain("Standalone implements IFoo")
expect(context).not.toContain("extends")
})
it("should handle class without implements", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [],
classes: [
{
name: "Child",
lineStart: 1,
lineEnd: 10,
methods: [],
properties: [],
extends: "Parent",
implements: [],
isExported: false,
isAbstract: false,
},
],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("child.ts", ast)
expect(context).toContain("Child extends Parent")
expect(context).not.toContain("implements")
})
it("should handle method with private visibility", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [],
classes: [
{
name: "WithPrivate",
lineStart: 1,
lineEnd: 20,
methods: [
{
name: "secretMethod",
lineStart: 5,
lineEnd: 10,
params: [],
isAsync: false,
visibility: "private",
isStatic: false,
},
],
properties: [],
implements: [],
isExported: false,
isAbstract: false,
},
],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("private.ts", ast)
expect(context).toContain("private secretMethod()")
})
it("should handle non-async function", () => {
const ast: FileAST = {
imports: [],
exports: [],
functions: [
{
name: "syncFn",
lineStart: 1,
lineEnd: 5,
params: [{ name: "x", optional: false, hasDefault: false }],
isAsync: false,
isExported: false,
},
],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("sync.ts", ast)
expect(context).toContain("syncFn(x)")
expect(context).not.toContain("async syncFn")
})
it("should handle export without default", () => {
const ast: FileAST = {
imports: [],
exports: [{ name: "foo", line: 1, isDefault: false, kind: "variable" }],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
}
const context = buildFileContext("named-export.ts", ast)
expect(context).toContain("variable foo")
expect(context).not.toContain("(default)")
})
})
describe("buildInitialContext - edge cases", () => {
it("should handle nested directory names", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: [],
directories: ["src/components/ui"],
}
const asts = new Map<string, FileAST>()
const context = buildInitialContext(structure, asts)
expect(context).toContain("ui/")
})
it("should handle file with only interfaces", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["types.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"types.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [{ name: "IFoo", lineStart: 1, lineEnd: 5, isExported: true }],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("interface: IFoo")
})
it("should handle file with only type aliases", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["types.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"types.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [
{ name: "MyType", lineStart: 1, lineEnd: 1, isExported: true },
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("type: MyType")
})
it("should handle file with no AST content", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["empty.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"empty.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- empty.ts")
})
it("should handle meta with only hub flag", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["hub.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"hub.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const metas = new Map<string, FileMeta>([
[
"hub.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: [],
isHub: true,
isEntryPoint: false,
fileType: "source",
},
],
])
const context = buildInitialContext(structure, asts, metas)
expect(context).toContain("(hub)")
expect(context).not.toContain("entry")
expect(context).not.toContain("complex")
})
it("should handle meta with no flags", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["normal.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"normal.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const metas = new Map<string, FileMeta>([
[
"normal.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: [],
isHub: false,
isEntryPoint: false,
fileType: "source",
},
],
])
const context = buildInitialContext(structure, asts, metas)
expect(context).toContain("- normal.ts")
expect(context).not.toContain("(hub")
expect(context).not.toContain("entry")
expect(context).not.toContain("complex")
})
it("should skip files not in AST map", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["exists.ts", "missing.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"exists.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("exists.ts")
expect(context).not.toContain("missing.ts")
})
})
describe("truncateContext", () => {
it("should return original context if within limit", () => {
const context = "Short context"

View File

@@ -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> = {}): FileMeta {
return {
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 },
dependencies: [],
dependents: [],
isHub: false,
isEntryPoint: false,
fileType: "source",
...partial,
}
}
function createMockStorage(metas: Map<string, FileMeta> = 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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
["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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>()
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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([["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<string, FileMeta>([
["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<string, FileMeta>([
[
"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<string, FileMeta>([["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<string, FileMeta>([["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<typeof vi.fn>).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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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)
})
})
})

View File

@@ -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> = {}): FileMeta {
return {
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 },
dependencies: [],
dependents: [],
isHub: false,
isEntryPoint: false,
fileType: "source",
...partial,
}
}
function createMockStorage(metas: Map<string, FileMeta> = 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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
["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<string, FileMeta>([
["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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([["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<string, FileMeta>([["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<typeof vi.fn>).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<string, FileMeta>([["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<string, FileMeta>([
["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)
})
})
})

View File

@@ -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> = {}): FileMeta {
return {
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 },
dependencies: [],
dependents: [],
isHub: false,
isEntryPoint: false,
fileType: "source",
...partial,
}
}
function createMockStorage(metas: Map<string, FileMeta> = 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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
["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<string, FileMeta>([
["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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([["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<string, FileMeta>([["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<typeof vi.fn>).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<string, FileMeta>([["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<string, FileMeta>([
[
"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)
})
})
})

View File

@@ -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<string, FileData> = 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<string, FileData>([
[
"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<string, FileData>([
[
"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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
[
"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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
[
"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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
["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<typeof vi.fn>).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<string, FileData>([
["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<string, FileData>([
["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<string, FileData>([
[
"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)
})
})
})

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { promises as fs } from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import {
CreateFileTool,
type CreateFileResult,
} from "../../../../../src/infrastructure/tools/edit/CreateFileTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import { hashLines } from "../../../../../src/shared/utils/hash.js"
function createMockStorage(): IStorage {
return {
getFile: vi.fn().mockResolvedValue(null),
setFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn(),
getAllFiles: vi.fn(),
getFileCount: vi.fn(),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn(),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn(),
getSymbolIndex: vi.fn(),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn(),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn(),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(
storage?: IStorage,
confirmResult = true,
projectRoot = "/test/project",
): ToolContext {
return {
projectRoot,
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
onProgress: vi.fn(),
}
}
describe("CreateFileTool", () => {
let tool: CreateFileTool
beforeEach(() => {
tool = new CreateFileTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("create_file")
})
it("should have correct category", () => {
expect(tool.category).toBe("edit")
})
it("should require confirmation", () => {
expect(tool.requiresConfirmation).toBe(true)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(2)
expect(tool.parameters[0].name).toBe("path")
expect(tool.parameters[0].required).toBe(true)
expect(tool.parameters[1].name).toBe("content")
expect(tool.parameters[1].required).toBe(true)
})
it("should have description mentioning confirmation", () => {
expect(tool.description).toContain("confirmation")
})
})
describe("validateParams", () => {
it("should return null for valid params", () => {
expect(
tool.validateParams({ path: "src/new-file.ts", content: "const x = 1" }),
).toBeNull()
})
it("should return error for missing path", () => {
expect(tool.validateParams({ content: "x" })).toBe(
"Parameter 'path' is required and must be a non-empty string",
)
})
it("should return error for empty path", () => {
expect(tool.validateParams({ path: "", content: "x" })).toBe(
"Parameter 'path' is required and must be a non-empty string",
)
expect(tool.validateParams({ path: " ", content: "x" })).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, content: "x" })).toBe(
"Parameter 'path' is required and must be a non-empty string",
)
})
it("should return error for missing content", () => {
expect(tool.validateParams({ path: "test.ts" })).toBe(
"Parameter 'content' is required and must be a string",
)
})
it("should return error for non-string content", () => {
expect(tool.validateParams({ path: "test.ts", content: 123 })).toBe(
"Parameter 'content' is required and must be a string",
)
})
it("should allow empty content string", () => {
expect(tool.validateParams({ path: "test.ts", content: "" })).toBeNull()
})
})
describe("execute", () => {
let tempDir: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "create-file-test-"))
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
it("should create new file with content", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const content = "line 1\nline 2\nline 3"
const result = await tool.execute({ path: "new-file.ts", content }, ctx)
expect(result.success).toBe(true)
const data = result.data as CreateFileResult
expect(data.path).toBe("new-file.ts")
expect(data.lines).toBe(3)
const filePath = path.join(tempDir, "new-file.ts")
const fileContent = await fs.readFile(filePath, "utf-8")
expect(fileContent).toBe(content)
})
it("should create directories if they do not exist", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "deep/nested/dir/file.ts", content: "test" },
ctx,
)
expect(result.success).toBe(true)
const filePath = path.join(tempDir, "deep/nested/dir/file.ts")
const fileContent = await fs.readFile(filePath, "utf-8")
expect(fileContent).toBe("test")
})
it("should call requestConfirmation with diff info", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
"Create new file: new-file.ts (2 lines)",
{
filePath: "new-file.ts",
oldLines: [],
newLines: ["line 1", "line 2"],
startLine: 1,
},
)
})
it("should cancel creation when confirmation rejected", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, false, tempDir)
const result = await tool.execute({ path: "new-file.ts", content: "test" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("File creation cancelled by user")
const filePath = path.join(tempDir, "new-file.ts")
await expect(fs.access(filePath)).rejects.toThrow()
})
it("should update storage after creation", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
expect(storage.setFile).toHaveBeenCalledWith(
"new-file.ts",
expect.objectContaining({
lines: ["line 1", "line 2"],
hash: hashLines(["line 1", "line 2"]),
}),
)
})
it("should return error for path outside project root", async () => {
const ctx = createMockContext(undefined, true, tempDir)
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root")
})
it("should return error if file already exists", async () => {
const existingFile = path.join(tempDir, "existing.ts")
await fs.writeFile(existingFile, "original content", "utf-8")
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "existing.ts", content: "new content" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("File already exists: existing.ts")
const content = await fs.readFile(existingFile, "utf-8")
expect(content).toBe("original content")
})
it("should handle empty content", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "empty.ts", content: "" }, ctx)
expect(result.success).toBe(true)
const data = result.data as CreateFileResult
expect(data.lines).toBe(1)
const filePath = path.join(tempDir, "empty.ts")
const fileContent = await fs.readFile(filePath, "utf-8")
expect(fileContent).toBe("")
})
it("should handle single line content", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "single.ts", content: "export const x = 1" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as CreateFileResult
expect(data.lines).toBe(1)
})
it("should return correct file size", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const content = "hello world"
const result = await tool.execute({ path: "file.ts", content }, ctx)
expect(result.success).toBe(true)
const data = result.data as CreateFileResult
expect(data.size).toBe(Buffer.byteLength(content, "utf-8"))
})
it("should include callId in result", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
expect(result.callId).toMatch(/^create_file-\d+$/)
})
it("should include executionTimeMs in result", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should handle multi-line content correctly", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const content = "import { x } from './x'\n\nexport function foo() {\n return x\n}\n"
const result = await tool.execute({ path: "foo.ts", content }, ctx)
expect(result.success).toBe(true)
const data = result.data as CreateFileResult
expect(data.lines).toBe(6)
const filePath = path.join(tempDir, "foo.ts")
const fileContent = await fs.readFile(filePath, "utf-8")
expect(fileContent).toBe(content)
})
it("should handle special characters in content", async () => {
const storage = createMockStorage()
const ctx = createMockContext(storage, true, tempDir)
const content = "const emoji = '🚀'\nconst quote = \"hello 'world'\""
const result = await tool.execute({ path: "special.ts", content }, ctx)
expect(result.success).toBe(true)
const filePath = path.join(tempDir, "special.ts")
const fileContent = await fs.readFile(filePath, "utf-8")
expect(fileContent).toBe(content)
})
})
})

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { promises as fs } from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import {
DeleteFileTool,
type DeleteFileResult,
} from "../../../../../src/infrastructure/tools/edit/DeleteFileTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
function createMockStorage(fileData: { lines: string[] } | null = null): IStorage {
return {
getFile: vi.fn().mockResolvedValue(fileData),
setFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
getAllFiles: vi.fn(),
getFileCount: vi.fn(),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn().mockResolvedValue(undefined),
getAllASTs: vi.fn(),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn().mockResolvedValue(undefined),
getAllMetas: vi.fn(),
getSymbolIndex: vi.fn(),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn(),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn(),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(
storage?: IStorage,
confirmResult = true,
projectRoot = "/test/project",
): ToolContext {
return {
projectRoot,
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
onProgress: vi.fn(),
}
}
describe("DeleteFileTool", () => {
let tool: DeleteFileTool
beforeEach(() => {
tool = new DeleteFileTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("delete_file")
})
it("should have correct category", () => {
expect(tool.category).toBe("edit")
})
it("should require confirmation", () => {
expect(tool.requiresConfirmation).toBe(true)
})
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 mentioning confirmation", () => {
expect(tool.description).toContain("confirmation")
})
})
describe("validateParams", () => {
it("should return null for valid params", () => {
expect(tool.validateParams({ path: "src/file.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",
)
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", () => {
let tempDir: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delete-file-test-"))
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
it("should delete existing file", async () => {
const testFile = path.join(tempDir, "to-delete.ts")
await fs.writeFile(testFile, "content to delete", "utf-8")
const storage = createMockStorage({ lines: ["content to delete"] })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "to-delete.ts" }, ctx)
expect(result.success).toBe(true)
const data = result.data as DeleteFileResult
expect(data.path).toBe("to-delete.ts")
expect(data.deleted).toBe(true)
await expect(fs.access(testFile)).rejects.toThrow()
})
it("should delete file from storage", async () => {
const testFile = path.join(tempDir, "to-delete.ts")
await fs.writeFile(testFile, "content", "utf-8")
const storage = createMockStorage({ lines: ["content"] })
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "to-delete.ts" }, ctx)
expect(storage.deleteFile).toHaveBeenCalledWith("to-delete.ts")
expect(storage.deleteAST).toHaveBeenCalledWith("to-delete.ts")
expect(storage.deleteMeta).toHaveBeenCalledWith("to-delete.ts")
})
it("should call requestConfirmation with diff info", async () => {
const testFile = path.join(tempDir, "to-delete.ts")
await fs.writeFile(testFile, "line 1\nline 2", "utf-8")
const storage = createMockStorage({ lines: ["line 1", "line 2"] })
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "to-delete.ts" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Delete file: to-delete.ts", {
filePath: "to-delete.ts",
oldLines: ["line 1", "line 2"],
newLines: [],
startLine: 1,
})
})
it("should cancel deletion when confirmation rejected", async () => {
const testFile = path.join(tempDir, "keep.ts")
await fs.writeFile(testFile, "keep this", "utf-8")
const storage = createMockStorage({ lines: ["keep this"] })
const ctx = createMockContext(storage, false, tempDir)
const result = await tool.execute({ path: "keep.ts" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("File deletion cancelled by user")
const content = await fs.readFile(testFile, "utf-8")
expect(content).toBe("keep this")
})
it("should return error for path outside project root", async () => {
const ctx = createMockContext(undefined, true, tempDir)
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root")
})
it("should return error if file does not exist", async () => {
const storage = createMockStorage(null)
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("File not found: nonexistent.ts")
})
it("should read content from filesystem if not in storage", async () => {
const testFile = path.join(tempDir, "not-indexed.ts")
await fs.writeFile(testFile, "filesystem content\nline 2", "utf-8")
const storage = createMockStorage(null)
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "not-indexed.ts" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
"Delete file: not-indexed.ts",
expect.objectContaining({
oldLines: ["filesystem content", "line 2"],
}),
)
})
it("should include callId in result", async () => {
const testFile = path.join(tempDir, "file.ts")
await fs.writeFile(testFile, "x", "utf-8")
const storage = createMockStorage({ lines: ["x"] })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "file.ts" }, ctx)
expect(result.callId).toMatch(/^delete_file-\d+$/)
})
it("should include executionTimeMs in result", async () => {
const testFile = path.join(tempDir, "file.ts")
await fs.writeFile(testFile, "x", "utf-8")
const storage = createMockStorage({ lines: ["x"] })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "file.ts" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should not delete directories", async () => {
const dirPath = path.join(tempDir, "some-dir")
await fs.mkdir(dirPath)
const storage = createMockStorage(null)
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "some-dir" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("File not found: some-dir")
})
it("should handle nested file paths", async () => {
const nestedDir = path.join(tempDir, "a/b/c")
await fs.mkdir(nestedDir, { recursive: true })
const testFile = path.join(nestedDir, "file.ts")
await fs.writeFile(testFile, "nested", "utf-8")
const storage = createMockStorage({ lines: ["nested"] })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute({ path: "a/b/c/file.ts" }, ctx)
expect(result.success).toBe(true)
await expect(fs.access(testFile)).rejects.toThrow()
})
})
})

View File

@@ -0,0 +1,493 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { promises as fs } from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import {
EditLinesTool,
type EditLinesResult,
} from "../../../../../src/infrastructure/tools/edit/EditLinesTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import { hashLines } from "../../../../../src/shared/utils/hash.js"
function createMockStorage(fileData: { lines: string[]; hash: string } | null = null): IStorage {
return {
getFile: vi.fn().mockResolvedValue(fileData),
setFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn(),
getAllFiles: vi.fn(),
getFileCount: vi.fn(),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn(),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn(),
getSymbolIndex: vi.fn(),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn(),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn(),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(
storage?: IStorage,
confirmResult = true,
projectRoot = "/test/project",
): ToolContext {
return {
projectRoot,
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
onProgress: vi.fn(),
}
}
describe("EditLinesTool", () => {
let tool: EditLinesTool
beforeEach(() => {
tool = new EditLinesTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("edit_lines")
})
it("should have correct category", () => {
expect(tool.category).toBe("edit")
})
it("should require confirmation", () => {
expect(tool.requiresConfirmation).toBe(true)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(4)
expect(tool.parameters[0].name).toBe("path")
expect(tool.parameters[0].required).toBe(true)
expect(tool.parameters[1].name).toBe("start")
expect(tool.parameters[1].required).toBe(true)
expect(tool.parameters[2].name).toBe("end")
expect(tool.parameters[2].required).toBe(true)
expect(tool.parameters[3].name).toBe("content")
expect(tool.parameters[3].required).toBe(true)
})
it("should have description mentioning confirmation", () => {
expect(tool.description).toContain("confirmation")
})
})
describe("validateParams", () => {
it("should return null for valid params", () => {
expect(
tool.validateParams({
path: "src/index.ts",
start: 1,
end: 5,
content: "new content",
}),
).toBeNull()
})
it("should return error for missing path", () => {
expect(tool.validateParams({ start: 1, end: 5, content: "x" })).toBe(
"Parameter 'path' is required and must be a non-empty string",
)
})
it("should return error for empty path", () => {
expect(tool.validateParams({ path: "", start: 1, end: 5, content: "x" })).toBe(
"Parameter 'path' is required and must be a non-empty string",
)
expect(tool.validateParams({ path: " ", start: 1, end: 5, content: "x" })).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, start: 1, end: 5, content: "x" })).toBe(
"Parameter 'path' is required and must be a non-empty string",
)
})
it("should return error for missing start", () => {
expect(tool.validateParams({ path: "test.ts", end: 5, content: "x" })).toBe(
"Parameter 'start' is required and must be an integer",
)
})
it("should return error for non-integer start", () => {
expect(tool.validateParams({ path: "test.ts", start: 1.5, end: 5, content: "x" })).toBe(
"Parameter 'start' is required and must be an integer",
)
expect(tool.validateParams({ path: "test.ts", start: "1", end: 5, content: "x" })).toBe(
"Parameter 'start' is required and must be an integer",
)
})
it("should return error for start < 1", () => {
expect(tool.validateParams({ path: "test.ts", start: 0, end: 5, content: "x" })).toBe(
"Parameter 'start' must be >= 1",
)
expect(tool.validateParams({ path: "test.ts", start: -1, end: 5, content: "x" })).toBe(
"Parameter 'start' must be >= 1",
)
})
it("should return error for missing end", () => {
expect(tool.validateParams({ path: "test.ts", start: 1, content: "x" })).toBe(
"Parameter 'end' is required and must be an integer",
)
})
it("should return error for non-integer end", () => {
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5.5, content: "x" })).toBe(
"Parameter 'end' is required and must be an integer",
)
})
it("should return error for end < 1", () => {
expect(tool.validateParams({ path: "test.ts", start: 1, end: 0, content: "x" })).toBe(
"Parameter 'end' must be >= 1",
)
})
it("should return error for start > end", () => {
expect(tool.validateParams({ path: "test.ts", start: 10, end: 5, content: "x" })).toBe(
"Parameter 'start' must be <= 'end'",
)
})
it("should return error for missing content", () => {
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5 })).toBe(
"Parameter 'content' is required and must be a string",
)
})
it("should return error for non-string content", () => {
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5, content: 123 })).toBe(
"Parameter 'content' is required and must be a string",
)
})
it("should allow empty content string", () => {
expect(
tool.validateParams({ path: "test.ts", start: 1, end: 5, content: "" }),
).toBeNull()
})
})
describe("execute", () => {
let tempDir: string
let testFilePath: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-lines-test-"))
testFilePath = path.join(tempDir, "test.ts")
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
it("should replace lines with new content", async () => {
const originalLines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
const originalContent = originalLines.join("\n")
await fs.writeFile(testFilePath, originalContent, "utf-8")
const lines = [...originalLines]
const hash = hashLines(lines)
const storage = createMockStorage({ lines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 2, end: 4, content: "new line A\nnew line B" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as EditLinesResult
expect(data.path).toBe("test.ts")
expect(data.startLine).toBe(2)
expect(data.endLine).toBe(4)
expect(data.linesReplaced).toBe(3)
expect(data.linesInserted).toBe(2)
expect(data.totalLines).toBe(4)
const newContent = await fs.readFile(testFilePath, "utf-8")
expect(newContent).toBe("line 1\nnew line A\nnew line B\nline 5")
})
it("should call requestConfirmation with diff info", async () => {
const originalLines = ["line 1", "line 2", "line 3"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "test.ts", start: 2, end: 2, content: "replaced" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Replace lines 2-2 in test.ts", {
filePath: "test.ts",
oldLines: ["line 2"],
newLines: ["replaced"],
startLine: 2,
})
})
it("should cancel edit when confirmation rejected", async () => {
const originalLines = ["line 1", "line 2", "line 3"]
const originalContent = originalLines.join("\n")
await fs.writeFile(testFilePath, originalContent, "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, false, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 1, content: "changed" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toBe("Edit cancelled by user")
const content = await fs.readFile(testFilePath, "utf-8")
expect(content).toBe(originalContent)
})
it("should update storage after edit", async () => {
const originalLines = ["line 1", "line 2"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
await tool.execute({ path: "test.ts", start: 1, end: 1, content: "changed" }, ctx)
expect(storage.setFile).toHaveBeenCalledWith(
"test.ts",
expect.objectContaining({
lines: ["changed", "line 2"],
hash: hashLines(["changed", "line 2"]),
}),
)
})
it("should return error for path outside project root", async () => {
const ctx = createMockContext()
const result = await tool.execute(
{ path: "../outside/file.ts", start: 1, end: 1, content: "x" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toBe("Path must be within project root")
})
it("should return error when start exceeds file length", async () => {
const originalLines = ["line 1", "line 2"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 10, end: 15, content: "x" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toBe("Start line 10 exceeds file length (2 lines)")
})
it("should adjust end to file length if it exceeds", async () => {
const originalLines = ["line 1", "line 2", "line 3"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 2, end: 100, content: "new" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as EditLinesResult
expect(data.endLine).toBe(3)
expect(data.linesReplaced).toBe(2)
})
it("should detect hash conflict", async () => {
const originalLines = ["line 1", "line 2"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const oldHash = hashLines(["old content"])
const storage = createMockStorage({ lines: originalLines, hash: oldHash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 1, content: "new" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toBe(
"File has been modified externally. Please refresh the file before editing.",
)
})
it("should allow edit when file not in storage", async () => {
const originalLines = ["line 1", "line 2"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const storage = createMockStorage(null)
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 1, content: "new" },
ctx,
)
expect(result.success).toBe(true)
})
it("should handle single line replacement", async () => {
const originalLines = ["line 1", "line 2", "line 3"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 2, end: 2, content: "replaced line 2" },
ctx,
)
expect(result.success).toBe(true)
const content = await fs.readFile(testFilePath, "utf-8")
expect(content).toBe("line 1\nreplaced line 2\nline 3")
})
it("should handle replacing all lines", async () => {
const originalLines = ["line 1", "line 2"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 2, content: "completely\nnew\nfile" },
ctx,
)
expect(result.success).toBe(true)
const content = await fs.readFile(testFilePath, "utf-8")
expect(content).toBe("completely\nnew\nfile")
})
it("should handle inserting more lines than replaced", async () => {
const originalLines = ["line 1", "line 2"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 1, content: "a\nb\nc\nd" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as EditLinesResult
expect(data.linesReplaced).toBe(1)
expect(data.linesInserted).toBe(4)
expect(data.totalLines).toBe(5)
})
it("should handle deleting lines (empty content)", async () => {
const originalLines = ["line 1", "line 2", "line 3"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 2, end: 2, content: "" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as EditLinesResult
expect(data.linesReplaced).toBe(1)
expect(data.linesInserted).toBe(1)
expect(data.totalLines).toBe(3)
})
it("should include callId in result", async () => {
const originalLines = ["line 1"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 1, content: "new" },
ctx,
)
expect(result.callId).toMatch(/^edit_lines-\d+$/)
})
it("should include executionTimeMs in result", async () => {
const originalLines = ["line 1"]
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
const hash = hashLines(originalLines)
const storage = createMockStorage({ lines: originalLines, hash })
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "test.ts", start: 1, end: 1, content: "new" },
ctx,
)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should return error when file not found", async () => {
const storage = createMockStorage(null)
const ctx = createMockContext(storage, true, tempDir)
const result = await tool.execute(
{ path: "nonexistent.ts", start: 1, end: 1, content: "x" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("ENOENT")
})
})
})

View File

@@ -0,0 +1,534 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
FindDefinitionTool,
type FindDefinitionResult,
} from "../../../../../src/infrastructure/tools/search/FindDefinitionTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type {
IStorage,
SymbolIndex,
SymbolLocation,
} 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<string, FileData> = new Map(),
symbolIndex: SymbolIndex = 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(symbolIndex),
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("FindDefinitionTool", () => {
let tool: FindDefinitionTool
beforeEach(() => {
tool = new FindDefinitionTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("find_definition")
})
it("should have correct category", () => {
expect(tool.category).toBe("search")
})
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("symbol")
expect(tool.parameters[0].required).toBe(true)
})
it("should have description", () => {
expect(tool.description).toContain("Find where a symbol is defined")
})
})
describe("validateParams", () => {
it("should return null for valid params", () => {
expect(tool.validateParams({ symbol: "myFunction" })).toBeNull()
})
it("should return error for missing symbol", () => {
expect(tool.validateParams({})).toBe(
"Parameter 'symbol' is required and must be a non-empty string",
)
})
it("should return error for empty symbol", () => {
expect(tool.validateParams({ symbol: "" })).toBe(
"Parameter 'symbol' is required and must be a non-empty string",
)
})
it("should return error for whitespace-only symbol", () => {
expect(tool.validateParams({ symbol: " " })).toBe(
"Parameter 'symbol' is required and must be a non-empty string",
)
})
})
describe("execute", () => {
it("should find function definition", async () => {
const files = new Map<string, FileData>([
[
"src/utils.ts",
createMockFileData([
"// Utility functions",
"export function myFunction() {",
" return 42",
"}",
]),
],
])
const symbolIndex: SymbolIndex = new Map([
["myFunction", [{ path: "src/utils.ts", line: 2, type: "function" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "myFunction" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.symbol).toBe("myFunction")
expect(data.found).toBe(true)
expect(data.definitions).toHaveLength(1)
expect(data.definitions[0].path).toBe("src/utils.ts")
expect(data.definitions[0].line).toBe(2)
expect(data.definitions[0].type).toBe("function")
})
it("should find class definition", async () => {
const files = new Map<string, FileData>([
[
"src/models.ts",
createMockFileData([
"export class User {",
" constructor(public name: string) {}",
"}",
]),
],
])
const symbolIndex: SymbolIndex = new Map([
["User", [{ path: "src/models.ts", line: 1, type: "class" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "User" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.definitions[0].type).toBe("class")
})
it("should find interface definition", async () => {
const files = new Map<string, FileData>([
[
"src/types.ts",
createMockFileData(["export interface Config {", " port: number", "}"]),
],
])
const symbolIndex: SymbolIndex = new Map([
["Config", [{ path: "src/types.ts", line: 1, type: "interface" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "Config" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.definitions[0].type).toBe("interface")
})
it("should find type alias definition", async () => {
const symbolIndex: SymbolIndex = new Map([
["ID", [{ path: "src/types.ts", line: 1, type: "type" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "ID" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.definitions[0].type).toBe("type")
})
it("should find variable definition", async () => {
const symbolIndex: SymbolIndex = new Map([
["DEFAULT_CONFIG", [{ path: "src/config.ts", line: 5, type: "variable" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "DEFAULT_CONFIG" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.definitions[0].type).toBe("variable")
})
it("should find multiple definitions (function overloads)", async () => {
const symbolIndex: SymbolIndex = new Map([
[
"process",
[
{ path: "src/a.ts", line: 1, type: "function" as const },
{ path: "src/b.ts", line: 5, type: "function" as const },
],
],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "process" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.definitions).toHaveLength(2)
})
it("should return not found for unknown symbol", async () => {
const symbolIndex: SymbolIndex = new Map()
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "unknownSymbol" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(false)
expect(data.definitions).toHaveLength(0)
})
it("should suggest similar symbols when not found", async () => {
const symbolIndex: SymbolIndex = new Map([
["myFunction", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
["myFunctionAsync", [{ path: "src/a.ts", line: 5, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "myFunc" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(false)
expect(data.suggestions).toBeDefined()
expect(data.suggestions).toContain("myFunction")
})
it("should not include suggestions when exact match found", async () => {
const symbolIndex: SymbolIndex = new Map([
["myFunction", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "myFunction" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.suggestions).toBeUndefined()
})
it("should include context lines", async () => {
const files = new Map<string, FileData>([
[
"src/test.ts",
createMockFileData([
"// Line 1",
"// Line 2",
"export function myFunc() {",
" return 1",
"}",
]),
],
])
const symbolIndex: SymbolIndex = new Map([
["myFunc", [{ path: "src/test.ts", line: 3, type: "function" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "myFunc" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
const context = data.definitions[0].context
expect(context).toContain("// Line 1")
expect(context).toContain("// Line 2")
expect(context).toContain("export function myFunc()")
expect(context).toContain("return 1")
expect(context).toContain("}")
})
it("should mark definition line in context", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["// before", "const foo = 1", "// after"])],
])
const symbolIndex: SymbolIndex = new Map([
["foo", [{ path: "src/test.ts", line: 2, type: "variable" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
const context = data.definitions[0].context
expect(context).toContain("> 2│const foo = 1")
expect(context).toContain(" 1│// before")
})
it("should handle context at file start", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const x = 1", "// after"])],
])
const symbolIndex: SymbolIndex = new Map([
["x", [{ path: "src/test.ts", line: 1, type: "variable" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
const context = data.definitions[0].context
expect(context).toContain("> 1│const x = 1")
})
it("should handle context at file end", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["// before", "const x = 1"])],
])
const symbolIndex: SymbolIndex = new Map([
["x", [{ path: "src/test.ts", line: 2, type: "variable" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
const context = data.definitions[0].context
expect(context).toContain("> 2│const x = 1")
})
it("should handle empty context when file not found", async () => {
const symbolIndex: SymbolIndex = new Map([
["foo", [{ path: "src/nonexistent.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(true)
expect(data.definitions[0].context).toBe("")
})
it("should sort definitions by path then line", async () => {
const symbolIndex: SymbolIndex = new Map([
[
"foo",
[
{ path: "src/b.ts", line: 10, type: "function" as const },
{ path: "src/a.ts", line: 5, type: "function" as const },
{ path: "src/b.ts", line: 1, type: "function" as const },
],
],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.definitions[0].path).toBe("src/a.ts")
expect(data.definitions[1].path).toBe("src/b.ts")
expect(data.definitions[1].line).toBe(1)
expect(data.definitions[2].path).toBe("src/b.ts")
expect(data.definitions[2].line).toBe(10)
})
it("should include callId in result", async () => {
const symbolIndex: SymbolIndex = new Map([
["x", [{ path: "src/a.ts", line: 1, type: "variable" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.callId).toMatch(/^find_definition-\d+$/)
})
it("should include execution time in result", async () => {
const symbolIndex: SymbolIndex = new Map()
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should handle storage errors gracefully", async () => {
const storage = createMockStorage()
;(storage.getSymbolIndex as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Redis connection failed"),
)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "test" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Redis connection failed")
})
it("should trim symbol before searching", async () => {
const symbolIndex: SymbolIndex = new Map([
["foo", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: " foo " }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.symbol).toBe("foo")
expect(data.found).toBe(true)
})
it("should suggest symbols with small edit distance", async () => {
const symbolIndex: SymbolIndex = new Map([
["fetchData", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "fethcData" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(false)
expect(data.suggestions).toContain("fetchData")
})
it("should limit suggestions to 5", async () => {
const symbolIndex: SymbolIndex = new Map([
["testA", [{ path: "a.ts", line: 1, type: "function" as const }]],
["testB", [{ path: "b.ts", line: 1, type: "function" as const }]],
["testC", [{ path: "c.ts", line: 1, type: "function" as const }]],
["testD", [{ path: "d.ts", line: 1, type: "function" as const }]],
["testE", [{ path: "e.ts", line: 1, type: "function" as const }]],
["testF", [{ path: "f.ts", line: 1, type: "function" as const }]],
["testG", [{ path: "g.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "test" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.suggestions).toBeDefined()
expect(data.suggestions!.length).toBeLessThanOrEqual(5)
})
it("should sort suggestions alphabetically", async () => {
const symbolIndex: SymbolIndex = new Map([
["testC", [{ path: "c.ts", line: 1, type: "function" as const }]],
["testA", [{ path: "a.ts", line: 1, type: "function" as const }]],
["testB", [{ path: "b.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "test" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.suggestions).toEqual(["testA", "testB", "testC"])
})
it("should not include suggestions when no similar symbols exist", async () => {
const symbolIndex: SymbolIndex = new Map([
["xyz", [{ path: "a.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(new Map(), symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "abc" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindDefinitionResult
expect(data.found).toBe(false)
expect(data.suggestions).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,564 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
FindReferencesTool,
type FindReferencesResult,
} from "../../../../../src/infrastructure/tools/search/FindReferencesTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type {
IStorage,
SymbolIndex,
SymbolLocation,
} 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<string, FileData> = new Map(),
symbolIndex: SymbolIndex = 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(symbolIndex),
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("FindReferencesTool", () => {
let tool: FindReferencesTool
beforeEach(() => {
tool = new FindReferencesTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("find_references")
})
it("should have correct category", () => {
expect(tool.category).toBe("search")
})
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("symbol")
expect(tool.parameters[0].required).toBe(true)
expect(tool.parameters[1].name).toBe("path")
expect(tool.parameters[1].required).toBe(false)
})
it("should have description", () => {
expect(tool.description).toContain("Find all usages")
})
})
describe("validateParams", () => {
it("should return null for valid params with symbol only", () => {
expect(tool.validateParams({ symbol: "myFunction" })).toBeNull()
})
it("should return null for valid params with symbol and path", () => {
expect(tool.validateParams({ symbol: "myFunction", path: "src/" })).toBeNull()
})
it("should return error for missing symbol", () => {
expect(tool.validateParams({})).toBe(
"Parameter 'symbol' is required and must be a non-empty string",
)
})
it("should return error for empty symbol", () => {
expect(tool.validateParams({ symbol: "" })).toBe(
"Parameter 'symbol' is required and must be a non-empty string",
)
})
it("should return error for whitespace-only symbol", () => {
expect(tool.validateParams({ symbol: " " })).toBe(
"Parameter 'symbol' is required and must be a non-empty string",
)
})
it("should return error for non-string path", () => {
expect(tool.validateParams({ symbol: "test", path: 123 })).toBe(
"Parameter 'path' must be a string",
)
})
})
describe("execute", () => {
it("should find simple symbol references", async () => {
const files = new Map<string, FileData>([
[
"src/index.ts",
createMockFileData([
"import { myFunction } from './utils'",
"",
"myFunction()",
"const result = myFunction(42)",
]),
],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "myFunction" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.symbol).toBe("myFunction")
expect(data.totalReferences).toBe(3)
expect(data.files).toBe(1)
expect(data.references).toHaveLength(3)
})
it("should find references across multiple files", async () => {
const files = new Map<string, FileData>([
["src/a.ts", createMockFileData(["const foo = 1", "console.log(foo)"])],
[
"src/b.ts",
createMockFileData(["import { foo } from './a'", "export const bar = foo + 1"]),
],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(4)
expect(data.files).toBe(2)
})
it("should include definition locations from symbol index", async () => {
const files = new Map<string, FileData>([
["src/utils.ts", createMockFileData(["export function helper() {}", "helper()"])],
])
const symbolIndex: SymbolIndex = new Map([
["helper", [{ path: "src/utils.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "helper" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.definitionLocations).toHaveLength(1)
expect(data.definitionLocations[0]).toEqual({
path: "src/utils.ts",
line: 1,
type: "function",
})
})
it("should mark definition lines", async () => {
const files = new Map<string, FileData>([
["src/utils.ts", createMockFileData(["export function myFunc() {}", "myFunc()"])],
])
const symbolIndex: SymbolIndex = new Map([
["myFunc", [{ path: "src/utils.ts", line: 1, type: "function" as const }]],
])
const storage = createMockStorage(files, symbolIndex)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "myFunc" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.references[0].isDefinition).toBe(true)
expect(data.references[1].isDefinition).toBe(false)
})
it("should filter by path", async () => {
const files = new Map<string, FileData>([
["src/a.ts", createMockFileData(["const x = 1"])],
["src/b.ts", createMockFileData(["const x = 2"])],
["lib/c.ts", createMockFileData(["const x = 3"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x", path: "src" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(2)
expect(data.references.every((r) => r.path.startsWith("src/"))).toBe(true)
})
it("should filter by specific file path", async () => {
const files = new Map<string, FileData>([
["src/a.ts", createMockFileData(["const x = 1"])],
["src/b.ts", createMockFileData(["const x = 2"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x", path: "src/a.ts" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(1)
expect(data.references[0].path).toBe("src/a.ts")
})
it("should return empty result when no files match filter", async () => {
const files = new Map<string, FileData>([
["src/a.ts", createMockFileData(["const x = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x", path: "nonexistent" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(0)
expect(data.files).toBe(0)
})
it("should return empty result when symbol not found", async () => {
const files = new Map<string, FileData>([
["src/a.ts", createMockFileData(["const foo = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "bar" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(0)
expect(data.files).toBe(0)
})
it("should use word boundaries for matching", async () => {
const files = new Map<string, FileData>([
[
"src/test.ts",
createMockFileData([
"const foo = 1",
"const foobar = 2",
"const barfoo = 3",
"const xfoox = 4",
]),
],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(1)
expect(data.references[0].line).toBe(1)
})
it("should include column number", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const value = 1", " value = 2"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "value" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.references[0].column).toBe(7)
expect(data.references[1].column).toBe(5)
})
it("should include context lines", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["// comment", "const foo = 1", "// after"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
const context = data.references[0].context
expect(context).toContain("// comment")
expect(context).toContain("const foo = 1")
expect(context).toContain("// after")
})
it("should mark current line in context", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["line1", "const foo = 1", "line3"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
const context = data.references[0].context
expect(context).toContain("> 2│const foo = 1")
expect(context).toContain(" 1│line1")
})
it("should handle context at file start", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const foo = 1", "line2"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
const context = data.references[0].context
expect(context).toContain("> 1│const foo = 1")
expect(context).toContain(" 2│line2")
})
it("should handle context at file end", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["line1", "const foo = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
const context = data.references[0].context
expect(context).toContain(" 1│line1")
expect(context).toContain("> 2│const foo = 1")
})
it("should find multiple occurrences on same line", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const x = x + x"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(3)
expect(data.references[0].column).toBe(7)
expect(data.references[1].column).toBe(11)
expect(data.references[2].column).toBe(15)
})
it("should sort results by path then line", async () => {
const files = new Map<string, FileData>([
["src/b.ts", createMockFileData(["x", "", "x"])],
["src/a.ts", createMockFileData(["x"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.references[0].path).toBe("src/a.ts")
expect(data.references[1].path).toBe("src/b.ts")
expect(data.references[1].line).toBe(1)
expect(data.references[2].path).toBe("src/b.ts")
expect(data.references[2].line).toBe(3)
})
it("should handle special regex characters in symbol", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const $value = 1", "$value + 2"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "$value" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(2)
})
it("should include callId in result", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const x = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.callId).toMatch(/^find_references-\d+$/)
})
it("should include execution time in result", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const x = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should handle storage errors gracefully", async () => {
const storage = createMockStorage()
;(storage.getSymbolIndex as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Redis connection failed"),
)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "test" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Redis connection failed")
})
it("should trim symbol before searching", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const foo = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: " foo " }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.symbol).toBe("foo")
expect(data.totalReferences).toBe(1)
})
it("should handle empty files", async () => {
const files = new Map<string, FileData>([
["src/empty.ts", createMockFileData([])],
["src/test.ts", createMockFileData(["const x = 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "x" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(1)
})
it("should handle symbols with underscores", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const my_variable = 1", "my_variable + 1"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "my_variable" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(2)
})
it("should handle symbols with numbers", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const value1 = 1", "value1 + value2"])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "value1" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(2)
})
it("should handle class method references", async () => {
const files = new Map<string, FileData>([
[
"src/test.ts",
createMockFileData([
"class Foo {",
" bar() {}",
"}",
"const f = new Foo()",
"f.bar()",
]),
],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "bar" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(2)
})
it("should not match partial words in strings", async () => {
const files = new Map<string, FileData>([
["src/test.ts", createMockFileData(["const foo = 1", 'const msg = "foobar"'])],
])
const storage = createMockStorage(files)
const ctx = createMockContext(storage)
const result = await tool.execute({ symbol: "foo" }, ctx)
expect(result.success).toBe(true)
const data = result.data as FindReferencesResult
expect(data.totalReferences).toBe(1)
expect(data.references[0].line).toBe(1)
})
})
})

View File

@@ -11,10 +11,10 @@ export default defineConfig({
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
lines: 95,
functions: 95,
branches: 90,
statements: 95,
},
},
},