mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
Compare commits
2 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae1ac13f5 | ||
|
|
caf7aac116 |
@@ -5,6 +5,79 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samiyev/ipuaro",
|
||||
"version": "0.6.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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/ipuaro/src/infrastructure/tools/analysis/index.ts
Normal file
20
packages/ipuaro/src/infrastructure/tools/analysis/index.ts
Normal 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"
|
||||
@@ -15,3 +15,41 @@ export {
|
||||
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"
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
12
packages/ipuaro/src/infrastructure/tools/search/index.ts
Normal file
12
packages/ipuaro/src/infrastructure/tools/search/index.ts
Normal 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"
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user