feat(ipuaro): add search tools (v0.7.0)

This commit is contained in:
imfozilbek
2025-12-01 02:05:27 +05:00
parent 4ad5a209c4
commit caf7aac116
8 changed files with 1634 additions and 1 deletions

View File

@@ -5,6 +5,36 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 ## [0.6.0] - 2025-12-01 - Edit Tools
### Added ### Added

View File

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

View File

@@ -15,3 +15,15 @@ export {
export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js" export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js"
export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js" export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js"
export { DeleteFileTool, type DeleteFileResult } from "./edit/DeleteFileTool.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"

View File

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

View File

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

View File

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

View File

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

View File

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