mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
Compare commits
5 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf7aac116 | ||
|
|
4ad5a209c4 | ||
|
|
25146003cc | ||
|
|
68f927d906 | ||
|
|
b3e04a411c |
@@ -40,7 +40,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/samiyev/puaros.git",
|
"url": "git+https://github.com/samiyev/puaros.git",
|
||||||
"directory": "packages/guardian"
|
"directory": "packages/guardian"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|||||||
@@ -5,6 +5,169 @@ 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **EditLinesTool (0.6.1)**
|
||||||
|
- `edit_lines(path, start, end, content)`: Replace lines in a file
|
||||||
|
- Hash conflict detection (prevents editing externally modified files)
|
||||||
|
- Confirmation required with diff preview
|
||||||
|
- Automatic storage update after edit
|
||||||
|
- 35 unit tests
|
||||||
|
|
||||||
|
- **CreateFileTool (0.6.2)**
|
||||||
|
- `create_file(path, content)`: Create new file with content
|
||||||
|
- Automatic directory creation if needed
|
||||||
|
- Path validation (must be within project root)
|
||||||
|
- Prevents overwriting existing files
|
||||||
|
- Confirmation required before creation
|
||||||
|
- 26 unit tests
|
||||||
|
|
||||||
|
- **DeleteFileTool (0.6.3)**
|
||||||
|
- `delete_file(path)`: Delete file from filesystem and storage
|
||||||
|
- Removes file data, AST, and meta from Redis
|
||||||
|
- Confirmation required with file content preview
|
||||||
|
- 20 unit tests
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Total tests: 664 (was 540)
|
||||||
|
- Coverage: 97.71% lines, 91.89% branches
|
||||||
|
- Coverage thresholds: 95% lines/functions/statements, 90% branches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.5.0] - 2025-12-01 - Read Tools
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ToolRegistry (0.5.1)**
|
||||||
|
- `IToolRegistry` implementation for managing tool lifecycle
|
||||||
|
- Methods: `register()`, `unregister()`, `get()`, `getAll()`, `getByCategory()`, `has()`
|
||||||
|
- `execute()`: Tool execution with validation and confirmation flow
|
||||||
|
- `getToolDefinitions()`: Convert tools to LLM-compatible JSON Schema format
|
||||||
|
- Helper methods: `getConfirmationTools()`, `getSafeTools()`, `getNames()`, `clear()`
|
||||||
|
- 34 unit tests
|
||||||
|
|
||||||
|
- **GetLinesTool (0.5.2)**
|
||||||
|
- `get_lines(path, start?, end?)`: Read file lines with line numbers
|
||||||
|
- Reads from Redis storage or filesystem fallback
|
||||||
|
- Line number formatting with proper padding
|
||||||
|
- Path validation (must be within project root)
|
||||||
|
- 25 unit tests
|
||||||
|
|
||||||
|
- **GetFunctionTool (0.5.3)**
|
||||||
|
- `get_function(path, name)`: Get function source by name
|
||||||
|
- Uses AST to find exact line range
|
||||||
|
- Returns metadata: isAsync, isExported, params, returnType
|
||||||
|
- Lists available functions if target not found
|
||||||
|
- 20 unit tests
|
||||||
|
|
||||||
|
- **GetClassTool (0.5.4)**
|
||||||
|
- `get_class(path, name)`: Get class source by name
|
||||||
|
- Uses AST to find exact line range
|
||||||
|
- Returns metadata: isAbstract, extends, implements, methods, properties
|
||||||
|
- Lists available classes if target not found
|
||||||
|
- 19 unit tests
|
||||||
|
|
||||||
|
- **GetStructureTool (0.5.5)**
|
||||||
|
- `get_structure(path?, depth?)`: Get directory tree
|
||||||
|
- ASCII tree output with 📁/📄 icons
|
||||||
|
- Filters: node_modules, .git, dist, coverage, etc.
|
||||||
|
- Directories sorted before files
|
||||||
|
- Stats: directory and file counts
|
||||||
|
- 23 unit tests
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Total tests: 540 (was 419)
|
||||||
|
- Coverage: 96%+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.0] - 2025-11-30 - LLM Integration
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OllamaClient (0.4.1)**
|
||||||
|
- Full `ILLMClient` implementation for Ollama SDK
|
||||||
|
- Chat completion with tool/function calling support
|
||||||
|
- Token counting via estimation (Ollama has no tokenizer API)
|
||||||
|
- Model management: `pullModel()`, `hasModel()`, `listModels()`
|
||||||
|
- Connection status check: `isAvailable()`
|
||||||
|
- Request abortion support: `abort()`
|
||||||
|
- Error handling with `IpuaroError` for connection and model errors
|
||||||
|
- 21 unit tests
|
||||||
|
|
||||||
|
- **System Prompt & Context Builder (0.4.2)**
|
||||||
|
- `SYSTEM_PROMPT`: Comprehensive agent instructions with tool descriptions
|
||||||
|
- `buildInitialContext()`: Generates compact project overview from structure and ASTs
|
||||||
|
- `buildFileContext()`: Detailed file context with imports, exports, functions, classes
|
||||||
|
- `truncateContext()`: Token-aware context truncation
|
||||||
|
- Hub/entry point/complexity flags in file summaries
|
||||||
|
- 17 unit tests
|
||||||
|
|
||||||
|
- **Tool Definitions (0.4.3)**
|
||||||
|
- 18 tool definitions across 6 categories:
|
||||||
|
- Read: `get_lines`, `get_function`, `get_class`, `get_structure`
|
||||||
|
- Edit: `edit_lines`, `create_file`, `delete_file`
|
||||||
|
- Search: `find_references`, `find_definition`
|
||||||
|
- Analysis: `get_dependencies`, `get_dependents`, `get_complexity`, `get_todos`
|
||||||
|
- Git: `git_status`, `git_diff`, `git_commit`
|
||||||
|
- Run: `run_command`, `run_tests`
|
||||||
|
- Category groupings: `READ_TOOLS`, `EDIT_TOOLS`, etc.
|
||||||
|
- `CONFIRMATION_TOOLS` set for tools requiring user approval
|
||||||
|
- Helper functions: `requiresConfirmation()`, `getToolDef()`, `getToolsByCategory()`
|
||||||
|
- 39 unit tests
|
||||||
|
|
||||||
|
- **Response Parser (0.4.4)**
|
||||||
|
- XML tool call parsing: `<tool_call name="...">...</tool_call>`
|
||||||
|
- Parameter extraction from XML elements
|
||||||
|
- Type coercion: boolean, number, null, JSON arrays/objects
|
||||||
|
- `extractThinking()`: Extracts `<thinking>...</thinking>` blocks
|
||||||
|
- `hasToolCalls()`: Quick check for tool call presence
|
||||||
|
- `validateToolCallParams()`: Parameter validation against required list
|
||||||
|
- `formatToolCallsAsXml()`: Tool calls to XML for prompt injection
|
||||||
|
- 21 unit tests
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Total tests: 419 (was 321)
|
||||||
|
- Coverage: 96.38%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.1] - 2025-11-30
|
## [0.3.1] - 2025-11-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/ipuaro",
|
"name": "@samiyev/ipuaro",
|
||||||
"version": "0.3.1",
|
"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",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/samiyev/puaros.git",
|
"url": "git+https://github.com/samiyev/puaros.git",
|
||||||
"directory": "packages/ipuaro"
|
"directory": "packages/ipuaro"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
// Infrastructure layer exports
|
// Infrastructure layer exports
|
||||||
export * from "./storage/index.js"
|
export * from "./storage/index.js"
|
||||||
export * from "./indexer/index.js"
|
export * from "./indexer/index.js"
|
||||||
|
export * from "./llm/index.js"
|
||||||
|
export * from "./tools/index.js"
|
||||||
|
|||||||
302
packages/ipuaro/src/infrastructure/llm/OllamaClient.ts
Normal file
302
packages/ipuaro/src/infrastructure/llm/OllamaClient.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { type Message, Ollama, type Tool } from "ollama"
|
||||||
|
import type {
|
||||||
|
ILLMClient,
|
||||||
|
LLMResponse,
|
||||||
|
ToolDef,
|
||||||
|
ToolParameter,
|
||||||
|
} from "../../domain/services/ILLMClient.js"
|
||||||
|
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
|
||||||
|
import { createToolCall, type ToolCall } from "../../domain/value-objects/ToolCall.js"
|
||||||
|
import type { LLMConfig } from "../../shared/constants/config.js"
|
||||||
|
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
|
||||||
|
import { estimateTokens } from "../../shared/utils/tokens.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama LLM client implementation.
|
||||||
|
* Wraps the Ollama SDK for chat completions with tool support.
|
||||||
|
*/
|
||||||
|
export class OllamaClient implements ILLMClient {
|
||||||
|
private readonly client: Ollama
|
||||||
|
private readonly host: string
|
||||||
|
private readonly model: string
|
||||||
|
private readonly contextWindow: number
|
||||||
|
private readonly temperature: number
|
||||||
|
private readonly timeout: number
|
||||||
|
private abortController: AbortController | null = null
|
||||||
|
|
||||||
|
constructor(config: LLMConfig) {
|
||||||
|
this.host = config.host
|
||||||
|
this.client = new Ollama({ host: this.host })
|
||||||
|
this.model = config.model
|
||||||
|
this.contextWindow = config.contextWindow
|
||||||
|
this.temperature = config.temperature
|
||||||
|
this.timeout = config.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send messages to LLM and get response.
|
||||||
|
*/
|
||||||
|
async chat(messages: ChatMessage[], tools?: ToolDef[]): Promise<LLMResponse> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ollamaMessages = this.convertMessages(messages)
|
||||||
|
const ollamaTools = tools ? this.convertTools(tools) : undefined
|
||||||
|
|
||||||
|
const response = await this.client.chat({
|
||||||
|
model: this.model,
|
||||||
|
messages: ollamaMessages,
|
||||||
|
tools: ollamaTools,
|
||||||
|
options: {
|
||||||
|
temperature: this.temperature,
|
||||||
|
},
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeMs = Date.now() - startTime
|
||||||
|
const toolCalls = this.extractToolCalls(response.message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: response.message.content,
|
||||||
|
toolCalls,
|
||||||
|
tokens: response.eval_count ?? estimateTokens(response.message.content),
|
||||||
|
timeMs,
|
||||||
|
truncated: false,
|
||||||
|
stopReason: this.determineStopReason(response, toolCalls),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
throw IpuaroError.llm("Request was aborted")
|
||||||
|
}
|
||||||
|
throw this.handleError(error)
|
||||||
|
} finally {
|
||||||
|
this.abortController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count tokens in text.
|
||||||
|
* Uses estimation since Ollama doesn't provide a tokenizer endpoint.
|
||||||
|
*/
|
||||||
|
async countTokens(text: string): Promise<number> {
|
||||||
|
return Promise.resolve(estimateTokens(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if LLM service is available.
|
||||||
|
*/
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.client.list()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current model name.
|
||||||
|
*/
|
||||||
|
getModelName(): string {
|
||||||
|
return this.model
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context window size.
|
||||||
|
*/
|
||||||
|
getContextWindowSize(): number {
|
||||||
|
return this.contextWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull/download model if not available locally.
|
||||||
|
*/
|
||||||
|
async pullModel(model: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client.pull({ model, stream: false })
|
||||||
|
} catch (error) {
|
||||||
|
throw this.handleError(error, `Failed to pull model: ${model}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific model is available locally.
|
||||||
|
*/
|
||||||
|
async hasModel(model: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.client.list()
|
||||||
|
return result.models.some((m) => m.name === model || m.name.startsWith(`${model}:`))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available models.
|
||||||
|
*/
|
||||||
|
async listModels(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.client.list()
|
||||||
|
return result.models.map((m) => m.name)
|
||||||
|
} catch (error) {
|
||||||
|
throw this.handleError(error, "Failed to list models")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort current generation.
|
||||||
|
*/
|
||||||
|
abort(): void {
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ChatMessage array to Ollama Message format.
|
||||||
|
*/
|
||||||
|
private convertMessages(messages: ChatMessage[]): Message[] {
|
||||||
|
return messages.map((msg): Message => {
|
||||||
|
const role = this.convertRole(msg.role)
|
||||||
|
|
||||||
|
if (msg.role === "tool" && msg.toolResults) {
|
||||||
|
return {
|
||||||
|
role: "tool",
|
||||||
|
content: msg.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: msg.content,
|
||||||
|
tool_calls: msg.toolCalls.map((tc) => ({
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.params,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
content: msg.content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert message role to Ollama role.
|
||||||
|
*/
|
||||||
|
private convertRole(role: ChatMessage["role"]): "user" | "assistant" | "system" | "tool" {
|
||||||
|
switch (role) {
|
||||||
|
case "user":
|
||||||
|
return "user"
|
||||||
|
case "assistant":
|
||||||
|
return "assistant"
|
||||||
|
case "system":
|
||||||
|
return "system"
|
||||||
|
case "tool":
|
||||||
|
return "tool"
|
||||||
|
default:
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ToolDef array to Ollama Tool format.
|
||||||
|
*/
|
||||||
|
private convertTools(tools: ToolDef[]): Tool[] {
|
||||||
|
return tools.map(
|
||||||
|
(tool): Tool => ({
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: this.convertParameters(tool.parameters),
|
||||||
|
required: tool.parameters.filter((p) => p.required).map((p) => p.name),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ToolParameter array to JSON Schema properties.
|
||||||
|
*/
|
||||||
|
private convertParameters(
|
||||||
|
params: ToolParameter[],
|
||||||
|
): Record<string, { type: string; description: string; enum?: string[] }> {
|
||||||
|
const properties: Record<string, { type: string; description: string; enum?: string[] }> =
|
||||||
|
{}
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
properties[param.name] = {
|
||||||
|
type: param.type,
|
||||||
|
description: param.description,
|
||||||
|
...(param.enum && { enum: param.enum }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract tool calls from Ollama response message.
|
||||||
|
*/
|
||||||
|
private extractToolCalls(message: Message): ToolCall[] {
|
||||||
|
if (!message.tool_calls || message.tool_calls.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.tool_calls.map((tc, index) =>
|
||||||
|
createToolCall(
|
||||||
|
`call_${String(Date.now())}_${String(index)}`,
|
||||||
|
tc.function.name,
|
||||||
|
tc.function.arguments,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine stop reason from response.
|
||||||
|
*/
|
||||||
|
private determineStopReason(
|
||||||
|
response: { done_reason?: string },
|
||||||
|
toolCalls: ToolCall[],
|
||||||
|
): "end" | "length" | "tool_use" {
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
return "tool_use"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.done_reason === "length") {
|
||||||
|
return "length"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle and wrap errors.
|
||||||
|
*/
|
||||||
|
private handleError(error: unknown, context?: string): IpuaroError {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
const fullMessage = context ? `${context}: ${message}` : message
|
||||||
|
|
||||||
|
if (message.includes("ECONNREFUSED") || message.includes("fetch failed")) {
|
||||||
|
return IpuaroError.llm(`Cannot connect to Ollama at ${this.host}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes("model") && message.includes("not found")) {
|
||||||
|
return IpuaroError.llm(
|
||||||
|
`Model "${this.model}" not found. Run: ollama pull ${this.model}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return IpuaroError.llm(fullMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
220
packages/ipuaro/src/infrastructure/llm/ResponseParser.ts
Normal file
220
packages/ipuaro/src/infrastructure/llm/ResponseParser.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { createToolCall, type ToolCall } from "../../domain/value-objects/ToolCall.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed response from LLM.
|
||||||
|
*/
|
||||||
|
export interface ParsedResponse {
|
||||||
|
/** Text content (excluding tool calls) */
|
||||||
|
content: string
|
||||||
|
/** Extracted tool calls */
|
||||||
|
toolCalls: ToolCall[]
|
||||||
|
/** Whether parsing encountered issues */
|
||||||
|
hasParseErrors: boolean
|
||||||
|
/** Parse error messages */
|
||||||
|
parseErrors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XML tool call tag pattern.
|
||||||
|
* Matches: <tool_call name="tool_name">...</tool_call>
|
||||||
|
*/
|
||||||
|
const TOOL_CALL_REGEX = /<tool_call\s+name\s*=\s*"([^"]+)">([\s\S]*?)<\/tool_call>/gi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XML parameter tag pattern.
|
||||||
|
* Matches: <param name="param_name">value</param> or <param_name>value</param_name>
|
||||||
|
*/
|
||||||
|
const PARAM_REGEX_NAMED = /<param\s+name\s*=\s*"([^"]+)">([\s\S]*?)<\/param>/gi
|
||||||
|
const PARAM_REGEX_ELEMENT = /<([a-z_][a-z0-9_]*)>([\s\S]*?)<\/\1>/gi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse tool calls from LLM response text.
|
||||||
|
* Supports XML format: <tool_call name="get_lines"><path>src/index.ts</path></tool_call>
|
||||||
|
*/
|
||||||
|
export function parseToolCalls(response: string): ParsedResponse {
|
||||||
|
const toolCalls: ToolCall[] = []
|
||||||
|
const parseErrors: string[] = []
|
||||||
|
let content = response
|
||||||
|
|
||||||
|
const matches = [...response.matchAll(TOOL_CALL_REGEX)]
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const [fullMatch, toolName, paramsXml] = match
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = parseParameters(paramsXml)
|
||||||
|
const toolCall = createToolCall(
|
||||||
|
`xml_${String(Date.now())}_${String(toolCalls.length)}`,
|
||||||
|
toolName,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
toolCalls.push(toolCall)
|
||||||
|
content = content.replace(fullMatch, "")
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
|
parseErrors.push(`Failed to parse tool call "${toolName}": ${errorMsg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content = content.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
toolCalls,
|
||||||
|
hasParseErrors: parseErrors.length > 0,
|
||||||
|
parseErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse parameters from XML content.
|
||||||
|
*/
|
||||||
|
function parseParameters(xml: string): Record<string, unknown> {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
const namedMatches = [...xml.matchAll(PARAM_REGEX_NAMED)]
|
||||||
|
for (const match of namedMatches) {
|
||||||
|
const [, name, value] = match
|
||||||
|
params[name] = parseValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (namedMatches.length === 0) {
|
||||||
|
const elementMatches = [...xml.matchAll(PARAM_REGEX_ELEMENT)]
|
||||||
|
for (const match of elementMatches) {
|
||||||
|
const [, name, value] = match
|
||||||
|
params[name] = parseValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a value string to appropriate type.
|
||||||
|
*/
|
||||||
|
function parseValue(value: string): unknown {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
|
||||||
|
if (trimmed === "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "false") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "null") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(trimmed)
|
||||||
|
if (!isNaN(num) && trimmed !== "") {
|
||||||
|
return num
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith("[") && trimmed.endsWith("]")) ||
|
||||||
|
(trimmed.startsWith("{") && trimmed.endsWith("}"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tool calls to XML for prompt injection.
|
||||||
|
* Useful when you need to show the LLM the expected format.
|
||||||
|
*/
|
||||||
|
export function formatToolCallsAsXml(toolCalls: ToolCall[]): string {
|
||||||
|
return toolCalls
|
||||||
|
.map((tc) => {
|
||||||
|
const params = Object.entries(tc.params)
|
||||||
|
.map(([key, value]) => ` <${key}>${formatValueForXml(value)}</${key}>`)
|
||||||
|
.join("\n")
|
||||||
|
return `<tool_call name="${tc.name}">\n${params}\n</tool_call>`
|
||||||
|
})
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a value for XML output.
|
||||||
|
*/
|
||||||
|
function formatValueForXml(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract thinking/reasoning from response.
|
||||||
|
* Matches content between <thinking>...</thinking> tags.
|
||||||
|
*/
|
||||||
|
export function extractThinking(response: string): { thinking: string; content: string } {
|
||||||
|
const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/gi
|
||||||
|
const matches = [...response.matchAll(thinkingRegex)]
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { thinking: "", content: response }
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = response
|
||||||
|
const thoughts: string[] = []
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
thoughts.push(match[1].trim())
|
||||||
|
content = content.replace(match[0], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thinking: thoughts.join("\n\n"),
|
||||||
|
content: content.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if response contains tool calls.
|
||||||
|
*/
|
||||||
|
export function hasToolCalls(response: string): boolean {
|
||||||
|
return TOOL_CALL_REGEX.test(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate tool call parameters against expected schema.
|
||||||
|
*/
|
||||||
|
export function validateToolCallParams(
|
||||||
|
toolName: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
requiredParams: string[],
|
||||||
|
): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const param of requiredParams) {
|
||||||
|
if (!(param in params) || params[param] === undefined || params[param] === null) {
|
||||||
|
errors.push(`Missing required parameter: ${param}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/ipuaro/src/infrastructure/llm/index.ts
Normal file
48
packages/ipuaro/src/infrastructure/llm/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// LLM infrastructure exports
|
||||||
|
export { OllamaClient } from "./OllamaClient.js"
|
||||||
|
export {
|
||||||
|
SYSTEM_PROMPT,
|
||||||
|
buildInitialContext,
|
||||||
|
buildFileContext,
|
||||||
|
truncateContext,
|
||||||
|
type ProjectStructure,
|
||||||
|
} from "./prompts.js"
|
||||||
|
export {
|
||||||
|
ALL_TOOLS,
|
||||||
|
READ_TOOLS,
|
||||||
|
EDIT_TOOLS,
|
||||||
|
SEARCH_TOOLS,
|
||||||
|
ANALYSIS_TOOLS,
|
||||||
|
GIT_TOOLS,
|
||||||
|
RUN_TOOLS,
|
||||||
|
CONFIRMATION_TOOLS,
|
||||||
|
requiresConfirmation,
|
||||||
|
getToolDef,
|
||||||
|
getToolsByCategory,
|
||||||
|
GET_LINES_TOOL,
|
||||||
|
GET_FUNCTION_TOOL,
|
||||||
|
GET_CLASS_TOOL,
|
||||||
|
GET_STRUCTURE_TOOL,
|
||||||
|
EDIT_LINES_TOOL,
|
||||||
|
CREATE_FILE_TOOL,
|
||||||
|
DELETE_FILE_TOOL,
|
||||||
|
FIND_REFERENCES_TOOL,
|
||||||
|
FIND_DEFINITION_TOOL,
|
||||||
|
GET_DEPENDENCIES_TOOL,
|
||||||
|
GET_DEPENDENTS_TOOL,
|
||||||
|
GET_COMPLEXITY_TOOL,
|
||||||
|
GET_TODOS_TOOL,
|
||||||
|
GIT_STATUS_TOOL,
|
||||||
|
GIT_DIFF_TOOL,
|
||||||
|
GIT_COMMIT_TOOL,
|
||||||
|
RUN_COMMAND_TOOL,
|
||||||
|
RUN_TESTS_TOOL,
|
||||||
|
} from "./toolDefs.js"
|
||||||
|
export {
|
||||||
|
parseToolCalls,
|
||||||
|
formatToolCallsAsXml,
|
||||||
|
extractThinking,
|
||||||
|
hasToolCalls,
|
||||||
|
validateToolCallParams,
|
||||||
|
type ParsedResponse,
|
||||||
|
} from "./ResponseParser.js"
|
||||||
335
packages/ipuaro/src/infrastructure/llm/prompts.ts
Normal file
335
packages/ipuaro/src/infrastructure/llm/prompts.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import type { FileAST } from "../../domain/value-objects/FileAST.js"
|
||||||
|
import type { FileMeta } from "../../domain/value-objects/FileMeta.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project structure for context building.
|
||||||
|
*/
|
||||||
|
export interface ProjectStructure {
|
||||||
|
name: string
|
||||||
|
rootPath: string
|
||||||
|
files: string[]
|
||||||
|
directories: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt for the ipuaro AI agent.
|
||||||
|
*/
|
||||||
|
export const SYSTEM_PROMPT = `You are ipuaro, a local AI code assistant specialized in helping developers understand and modify their codebase. You operate within a single project directory and have access to powerful tools for reading, searching, analyzing, and editing code.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Lazy Loading**: You don't have the full code in context. Use tools to fetch exactly what you need.
|
||||||
|
2. **Precision**: Always verify file paths and line numbers before making changes.
|
||||||
|
3. **Safety**: Confirm destructive operations. Never execute dangerous commands.
|
||||||
|
4. **Efficiency**: Minimize context usage. Request only necessary code sections.
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### Reading Tools
|
||||||
|
- \`get_lines\`: Get specific lines from a file
|
||||||
|
- \`get_function\`: Get a function by name
|
||||||
|
- \`get_class\`: Get a class by name
|
||||||
|
- \`get_structure\`: Get project directory structure
|
||||||
|
|
||||||
|
### Editing Tools (require confirmation)
|
||||||
|
- \`edit_lines\`: Replace specific lines in a file
|
||||||
|
- \`create_file\`: Create a new file
|
||||||
|
- \`delete_file\`: Delete a file
|
||||||
|
|
||||||
|
### Search Tools
|
||||||
|
- \`find_references\`: Find all usages of a symbol
|
||||||
|
- \`find_definition\`: Find where a symbol is defined
|
||||||
|
|
||||||
|
### Analysis Tools
|
||||||
|
- \`get_dependencies\`: Get files this file imports
|
||||||
|
- \`get_dependents\`: Get files that import this file
|
||||||
|
- \`get_complexity\`: Get complexity metrics
|
||||||
|
- \`get_todos\`: Find TODO/FIXME comments
|
||||||
|
|
||||||
|
### Git Tools
|
||||||
|
- \`git_status\`: Get repository status
|
||||||
|
- \`git_diff\`: Get uncommitted changes
|
||||||
|
- \`git_commit\`: Create a commit (requires confirmation)
|
||||||
|
|
||||||
|
### Run Tools
|
||||||
|
- \`run_command\`: Execute a shell command (security checked)
|
||||||
|
- \`run_tests\`: Run the test suite
|
||||||
|
|
||||||
|
## Response Guidelines
|
||||||
|
|
||||||
|
1. **Be concise**: Don't repeat information already in context.
|
||||||
|
2. **Show your work**: Explain what tools you're using and why.
|
||||||
|
3. **Verify before editing**: Always read the target code before modifying it.
|
||||||
|
4. **Handle errors gracefully**: If a tool fails, explain what went wrong and suggest alternatives.
|
||||||
|
|
||||||
|
## Code Editing Rules
|
||||||
|
|
||||||
|
1. Always use \`get_lines\` or \`get_function\` before \`edit_lines\`.
|
||||||
|
2. Provide exact line numbers for edits.
|
||||||
|
3. For large changes, break into multiple small edits.
|
||||||
|
4. After editing, suggest running tests if available.
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
1. Never execute commands that could harm the system.
|
||||||
|
2. Never expose sensitive data (API keys, passwords).
|
||||||
|
3. Always confirm file deletions and destructive git operations.
|
||||||
|
4. Stay within the project directory.
|
||||||
|
|
||||||
|
When you need to perform an action, use the appropriate tool. Think step by step about what information you need and which tools will provide it most efficiently.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build initial context from project structure and AST metadata.
|
||||||
|
* Returns a compact representation without actual code.
|
||||||
|
*/
|
||||||
|
export function buildInitialContext(
|
||||||
|
structure: ProjectStructure,
|
||||||
|
asts: Map<string, FileAST>,
|
||||||
|
metas?: Map<string, FileMeta>,
|
||||||
|
): string {
|
||||||
|
const sections: string[] = []
|
||||||
|
|
||||||
|
sections.push(formatProjectHeader(structure))
|
||||||
|
sections.push(formatDirectoryTree(structure))
|
||||||
|
sections.push(formatFileOverview(asts, metas))
|
||||||
|
|
||||||
|
return sections.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format project header section.
|
||||||
|
*/
|
||||||
|
function formatProjectHeader(structure: ProjectStructure): string {
|
||||||
|
const fileCount = String(structure.files.length)
|
||||||
|
const dirCount = String(structure.directories.length)
|
||||||
|
return `# Project: ${structure.name}
|
||||||
|
Root: ${structure.rootPath}
|
||||||
|
Files: ${fileCount} | Directories: ${dirCount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format directory tree.
|
||||||
|
*/
|
||||||
|
function formatDirectoryTree(structure: ProjectStructure): string {
|
||||||
|
const lines: string[] = ["## Structure", ""]
|
||||||
|
|
||||||
|
const sortedDirs = [...structure.directories].sort()
|
||||||
|
for (const dir of sortedDirs) {
|
||||||
|
const depth = dir.split("/").length - 1
|
||||||
|
const indent = " ".repeat(depth)
|
||||||
|
const name = dir.split("/").pop() ?? dir
|
||||||
|
lines.push(`${indent}${name}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file overview with AST summaries.
|
||||||
|
*/
|
||||||
|
function formatFileOverview(asts: Map<string, FileAST>, metas?: Map<string, FileMeta>): string {
|
||||||
|
const lines: string[] = ["## Files", ""]
|
||||||
|
|
||||||
|
const sortedPaths = [...asts.keys()].sort()
|
||||||
|
for (const path of sortedPaths) {
|
||||||
|
const ast = asts.get(path)
|
||||||
|
if (!ast) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = metas?.get(path)
|
||||||
|
lines.push(formatFileSummary(path, ast, meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single file's AST summary.
|
||||||
|
*/
|
||||||
|
function formatFileSummary(path: string, ast: FileAST, meta?: FileMeta): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (ast.functions.length > 0) {
|
||||||
|
const names = ast.functions.map((f) => f.name).join(", ")
|
||||||
|
parts.push(`fn: ${names}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ast.classes.length > 0) {
|
||||||
|
const names = ast.classes.map((c) => c.name).join(", ")
|
||||||
|
parts.push(`class: ${names}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ast.interfaces.length > 0) {
|
||||||
|
const names = ast.interfaces.map((i) => i.name).join(", ")
|
||||||
|
parts.push(`interface: ${names}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ast.typeAliases.length > 0) {
|
||||||
|
const names = ast.typeAliases.map((t) => t.name).join(", ")
|
||||||
|
parts.push(`type: ${names}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = parts.length > 0 ? ` [${parts.join(" | ")}]` : ""
|
||||||
|
const flags = formatFileFlags(meta)
|
||||||
|
|
||||||
|
return `- ${path}${summary}${flags}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file metadata flags.
|
||||||
|
*/
|
||||||
|
function formatFileFlags(meta?: FileMeta): string {
|
||||||
|
if (!meta) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags: string[] = []
|
||||||
|
|
||||||
|
if (meta.isHub) {
|
||||||
|
flags.push("hub")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.isEntryPoint) {
|
||||||
|
flags.push("entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.complexity.score > 70) {
|
||||||
|
flags.push("complex")
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags.length > 0 ? ` (${flags.join(", ")})` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format line range for display.
|
||||||
|
*/
|
||||||
|
function formatLineRange(start: number, end: number): string {
|
||||||
|
return `[${String(start)}-${String(end)}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format imports section.
|
||||||
|
*/
|
||||||
|
function formatImportsSection(ast: FileAST): string[] {
|
||||||
|
if (ast.imports.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const lines = ["### Imports"]
|
||||||
|
for (const imp of ast.imports) {
|
||||||
|
lines.push(`- ${imp.name} from "${imp.from}" (${imp.type})`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format exports section.
|
||||||
|
*/
|
||||||
|
function formatExportsSection(ast: FileAST): string[] {
|
||||||
|
if (ast.exports.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const lines = ["### Exports"]
|
||||||
|
for (const exp of ast.exports) {
|
||||||
|
const defaultMark = exp.isDefault ? " (default)" : ""
|
||||||
|
lines.push(`- ${exp.kind} ${exp.name}${defaultMark}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format functions section.
|
||||||
|
*/
|
||||||
|
function formatFunctionsSection(ast: FileAST): string[] {
|
||||||
|
if (ast.functions.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const lines = ["### Functions"]
|
||||||
|
for (const fn of ast.functions) {
|
||||||
|
const params = fn.params.map((p) => p.name).join(", ")
|
||||||
|
const asyncMark = fn.isAsync ? "async " : ""
|
||||||
|
const range = formatLineRange(fn.lineStart, fn.lineEnd)
|
||||||
|
lines.push(`- ${asyncMark}${fn.name}(${params}) ${range}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format classes section.
|
||||||
|
*/
|
||||||
|
function formatClassesSection(ast: FileAST): string[] {
|
||||||
|
if (ast.classes.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const lines = ["### Classes"]
|
||||||
|
for (const cls of ast.classes) {
|
||||||
|
const ext = cls.extends ? ` extends ${cls.extends}` : ""
|
||||||
|
const impl = cls.implements.length > 0 ? ` implements ${cls.implements.join(", ")}` : ""
|
||||||
|
const range = formatLineRange(cls.lineStart, cls.lineEnd)
|
||||||
|
lines.push(`- ${cls.name}${ext}${impl} ${range}`)
|
||||||
|
|
||||||
|
for (const method of cls.methods) {
|
||||||
|
const vis = method.visibility === "public" ? "" : `${method.visibility} `
|
||||||
|
const methodRange = formatLineRange(method.lineStart, method.lineEnd)
|
||||||
|
lines.push(` - ${vis}${method.name}() ${methodRange}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format metadata section.
|
||||||
|
*/
|
||||||
|
function formatMetadataSection(meta: FileMeta): string[] {
|
||||||
|
const loc = String(meta.complexity.loc)
|
||||||
|
const score = String(meta.complexity.score)
|
||||||
|
const deps = String(meta.dependencies.length)
|
||||||
|
const dependents = String(meta.dependents.length)
|
||||||
|
return [
|
||||||
|
"### Metadata",
|
||||||
|
`- LOC: ${loc}`,
|
||||||
|
`- Complexity: ${score}/100`,
|
||||||
|
`- Dependencies: ${deps}`,
|
||||||
|
`- Dependents: ${dependents}`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build context for a specific file request.
|
||||||
|
*/
|
||||||
|
export function buildFileContext(path: string, ast: FileAST, meta?: FileMeta): string {
|
||||||
|
const lines: string[] = [`## ${path}`, ""]
|
||||||
|
|
||||||
|
lines.push(...formatImportsSection(ast))
|
||||||
|
lines.push(...formatExportsSection(ast))
|
||||||
|
lines.push(...formatFunctionsSection(ast))
|
||||||
|
lines.push(...formatClassesSection(ast))
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
lines.push(...formatMetadataSection(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate context to fit within token budget.
|
||||||
|
*/
|
||||||
|
export function truncateContext(context: string, maxTokens: number): string {
|
||||||
|
const charsPerToken = 4
|
||||||
|
const maxChars = maxTokens * charsPerToken
|
||||||
|
|
||||||
|
if (context.length <= maxChars) {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = context.slice(0, maxChars - 100)
|
||||||
|
const lastNewline = truncated.lastIndexOf("\n")
|
||||||
|
const remaining = String(context.length - lastNewline)
|
||||||
|
|
||||||
|
return `${truncated.slice(0, lastNewline)}\n\n... (truncated, ${remaining} chars remaining)`
|
||||||
|
}
|
||||||
511
packages/ipuaro/src/infrastructure/llm/toolDefs.ts
Normal file
511
packages/ipuaro/src/infrastructure/llm/toolDefs.ts
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import type { ToolDef } from "../../domain/services/ILLMClient.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool definitions for ipuaro LLM.
|
||||||
|
* 18 tools across 6 categories: read, edit, search, analysis, git, run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Read Tools (4)
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GET_LINES_TOOL: ToolDef = {
|
||||||
|
name: "get_lines",
|
||||||
|
description:
|
||||||
|
"Get specific lines from a file. Returns the content with line numbers. " +
|
||||||
|
"If no range is specified, returns the entire file.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start",
|
||||||
|
type: "number",
|
||||||
|
description: "Start line number (1-based, inclusive)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "end",
|
||||||
|
type: "number",
|
||||||
|
description: "End line number (1-based, inclusive)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET_FUNCTION_TOOL: ToolDef = {
|
||||||
|
name: "get_function",
|
||||||
|
description:
|
||||||
|
"Get a function's source code by name. Uses AST to find exact line range. " +
|
||||||
|
"Returns the function code with line numbers.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "string",
|
||||||
|
description: "Function name to retrieve",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET_CLASS_TOOL: ToolDef = {
|
||||||
|
name: "get_class",
|
||||||
|
description:
|
||||||
|
"Get a class's source code by name. Uses AST to find exact line range. " +
|
||||||
|
"Returns the class code with line numbers.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "string",
|
||||||
|
description: "Class name to retrieve",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET_STRUCTURE_TOOL: ToolDef = {
|
||||||
|
name: "get_structure",
|
||||||
|
description:
|
||||||
|
"Get project directory structure as a tree. " +
|
||||||
|
"If path is specified, shows structure of that subdirectory only.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "Subdirectory path relative to project root (optional, defaults to root)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "depth",
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum depth to traverse (default: unlimited)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Edit Tools (3) - All require confirmation
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EDIT_LINES_TOOL: ToolDef = {
|
||||||
|
name: "edit_lines",
|
||||||
|
description:
|
||||||
|
"Replace lines in a file with new content. Requires reading the file first. " +
|
||||||
|
"Will show diff and ask for confirmation before applying.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start",
|
||||||
|
type: "number",
|
||||||
|
description: "Start line number (1-based, inclusive) to replace",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "end",
|
||||||
|
type: "number",
|
||||||
|
description: "End line number (1-based, inclusive) to replace",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
type: "string",
|
||||||
|
description: "New content to insert (can be multiple lines)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREATE_FILE_TOOL: ToolDef = {
|
||||||
|
name: "create_file",
|
||||||
|
description:
|
||||||
|
"Create a new file with specified content. " +
|
||||||
|
"Will fail if file already exists. Will ask for confirmation.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
type: "string",
|
||||||
|
description: "File content",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DELETE_FILE_TOOL: ToolDef = {
|
||||||
|
name: "delete_file",
|
||||||
|
description:
|
||||||
|
"Delete a file from the project. " +
|
||||||
|
"Will ask for confirmation. Previous content is saved to undo stack.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Search Tools (2)
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FIND_REFERENCES_TOOL: ToolDef = {
|
||||||
|
name: "find_references",
|
||||||
|
description:
|
||||||
|
"Find all usages of a symbol across the codebase. " +
|
||||||
|
"Returns list of file paths, line numbers, and context.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIND_DEFINITION_TOOL: ToolDef = {
|
||||||
|
name: "find_definition",
|
||||||
|
description:
|
||||||
|
"Find where a symbol is defined. " + "Returns file path, line number, and symbol type.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "symbol",
|
||||||
|
type: "string",
|
||||||
|
description: "Symbol name to find definition for",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Analysis Tools (4)
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GET_DEPENDENCIES_TOOL: ToolDef = {
|
||||||
|
name: "get_dependencies",
|
||||||
|
description:
|
||||||
|
"Get files that this file imports (internal dependencies). " +
|
||||||
|
"Returns list of imported file paths.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET_DEPENDENTS_TOOL: ToolDef = {
|
||||||
|
name: "get_dependents",
|
||||||
|
description:
|
||||||
|
"Get files that import this file (reverse dependencies). " +
|
||||||
|
"Returns list of file paths that depend on this file.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET_COMPLEXITY_TOOL: ToolDef = {
|
||||||
|
name: "get_complexity",
|
||||||
|
description:
|
||||||
|
"Get complexity metrics for a file or the entire project. " +
|
||||||
|
"Returns LOC, nesting depth, cyclomatic complexity, and overall score.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path (optional, defaults to all files sorted by complexity)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit",
|
||||||
|
type: "number",
|
||||||
|
description: "Max files to return when showing all (default: 10)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET_TODOS_TOOL: ToolDef = {
|
||||||
|
name: "get_todos",
|
||||||
|
description:
|
||||||
|
"Find TODO, FIXME, HACK, and XXX comments in the codebase. " +
|
||||||
|
"Returns list with file paths, line numbers, and comment text.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "Limit search to specific file or directory",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
type: "string",
|
||||||
|
description: "Filter by comment type",
|
||||||
|
required: false,
|
||||||
|
enum: ["TODO", "FIXME", "HACK", "XXX"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Git Tools (3)
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GIT_STATUS_TOOL: ToolDef = {
|
||||||
|
name: "git_status",
|
||||||
|
description:
|
||||||
|
"Get current git repository status. " +
|
||||||
|
"Returns branch name, staged files, modified files, and untracked files.",
|
||||||
|
parameters: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GIT_DIFF_TOOL: ToolDef = {
|
||||||
|
name: "git_diff",
|
||||||
|
description:
|
||||||
|
"Get uncommitted changes (diff). " + "Shows what has changed but not yet committed.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "Limit diff to specific file or directory",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "staged",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Show only staged changes (default: false, shows all)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GIT_COMMIT_TOOL: ToolDef = {
|
||||||
|
name: "git_commit",
|
||||||
|
description:
|
||||||
|
"Create a git commit with the specified message. " +
|
||||||
|
"Will ask for confirmation. Optionally stage specific files first.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "message",
|
||||||
|
type: "string",
|
||||||
|
description: "Commit message",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "files",
|
||||||
|
type: "array",
|
||||||
|
description: "Files to stage before commit (optional, defaults to all staged)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Run Tools (2)
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const RUN_COMMAND_TOOL: ToolDef = {
|
||||||
|
name: "run_command",
|
||||||
|
description:
|
||||||
|
"Execute a shell command in the project directory. " +
|
||||||
|
"Commands are checked against blacklist/whitelist for security. " +
|
||||||
|
"Unknown commands require user confirmation.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "command",
|
||||||
|
type: "string",
|
||||||
|
description: "Shell command to execute",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timeout",
|
||||||
|
type: "number",
|
||||||
|
description: "Timeout in milliseconds (default: 30000)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUN_TESTS_TOOL: ToolDef = {
|
||||||
|
name: "run_tests",
|
||||||
|
description:
|
||||||
|
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
|
||||||
|
"Returns test results summary.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "Run tests for specific file or directory",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filter",
|
||||||
|
type: "string",
|
||||||
|
description: "Filter tests by name pattern",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "watch",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Run in watch mode (default: false)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* =============================================================================
|
||||||
|
* Tool Collection
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All read tools (no confirmation required).
|
||||||
|
*/
|
||||||
|
export const READ_TOOLS: ToolDef[] = [
|
||||||
|
GET_LINES_TOOL,
|
||||||
|
GET_FUNCTION_TOOL,
|
||||||
|
GET_CLASS_TOOL,
|
||||||
|
GET_STRUCTURE_TOOL,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All edit tools (require confirmation).
|
||||||
|
*/
|
||||||
|
export const EDIT_TOOLS: ToolDef[] = [EDIT_LINES_TOOL, CREATE_FILE_TOOL, DELETE_FILE_TOOL]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All search tools (no confirmation required).
|
||||||
|
*/
|
||||||
|
export const SEARCH_TOOLS: ToolDef[] = [FIND_REFERENCES_TOOL, FIND_DEFINITION_TOOL]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All analysis tools (no confirmation required).
|
||||||
|
*/
|
||||||
|
export const ANALYSIS_TOOLS: ToolDef[] = [
|
||||||
|
GET_DEPENDENCIES_TOOL,
|
||||||
|
GET_DEPENDENTS_TOOL,
|
||||||
|
GET_COMPLEXITY_TOOL,
|
||||||
|
GET_TODOS_TOOL,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All git tools (git_commit requires confirmation).
|
||||||
|
*/
|
||||||
|
export const GIT_TOOLS: ToolDef[] = [GIT_STATUS_TOOL, GIT_DIFF_TOOL, GIT_COMMIT_TOOL]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All run tools (run_command may require confirmation).
|
||||||
|
*/
|
||||||
|
export const RUN_TOOLS: ToolDef[] = [RUN_COMMAND_TOOL, RUN_TESTS_TOOL]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All 18 tool definitions.
|
||||||
|
*/
|
||||||
|
export const ALL_TOOLS: ToolDef[] = [
|
||||||
|
...READ_TOOLS,
|
||||||
|
...EDIT_TOOLS,
|
||||||
|
...SEARCH_TOOLS,
|
||||||
|
...ANALYSIS_TOOLS,
|
||||||
|
...GIT_TOOLS,
|
||||||
|
...RUN_TOOLS,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools that require user confirmation before execution.
|
||||||
|
*/
|
||||||
|
export const CONFIRMATION_TOOLS = new Set([
|
||||||
|
"edit_lines",
|
||||||
|
"create_file",
|
||||||
|
"delete_file",
|
||||||
|
"git_commit",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool requires confirmation.
|
||||||
|
*/
|
||||||
|
export function requiresConfirmation(toolName: string): boolean {
|
||||||
|
return CONFIRMATION_TOOLS.has(toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool definition by name.
|
||||||
|
*/
|
||||||
|
export function getToolDef(name: string): ToolDef | undefined {
|
||||||
|
return ALL_TOOLS.find((t) => t.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool definitions by category.
|
||||||
|
*/
|
||||||
|
export function getToolsByCategory(category: string): ToolDef[] {
|
||||||
|
switch (category) {
|
||||||
|
case "read":
|
||||||
|
return READ_TOOLS
|
||||||
|
case "edit":
|
||||||
|
return EDIT_TOOLS
|
||||||
|
case "search":
|
||||||
|
return SEARCH_TOOLS
|
||||||
|
case "analysis":
|
||||||
|
return ANALYSIS_TOOLS
|
||||||
|
case "git":
|
||||||
|
return GIT_TOOLS
|
||||||
|
case "run":
|
||||||
|
return RUN_TOOLS
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
140
packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts
Normal file
140
packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
|
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { hashLines } from "../../../shared/utils/hash.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from create_file tool.
|
||||||
|
*/
|
||||||
|
export interface CreateFileResult {
|
||||||
|
path: string
|
||||||
|
lines: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for creating new files.
|
||||||
|
* Creates a new file with the specified content.
|
||||||
|
* Requires user confirmation before creating.
|
||||||
|
*/
|
||||||
|
export class CreateFileTool implements ITool {
|
||||||
|
readonly name = "create_file"
|
||||||
|
readonly description =
|
||||||
|
"Create a new file with the specified content. " +
|
||||||
|
"The file path must be within the project root. " +
|
||||||
|
"Requires confirmation before creating."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
type: "string",
|
||||||
|
description: "File content",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = true
|
||||||
|
readonly category = "edit" as const
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||||
|
return "Parameter 'path' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.content !== "string") {
|
||||||
|
return "Parameter 'content' is required and must be a string"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = params.path as string
|
||||||
|
const content = params.content as string
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await this.fileExists(absolutePath)
|
||||||
|
if (exists) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`File already exists: ${relativePath}`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split("\n")
|
||||||
|
|
||||||
|
const confirmed = await ctx.requestConfirmation(
|
||||||
|
`Create new file: ${relativePath} (${String(lines.length)} lines)`,
|
||||||
|
{
|
||||||
|
filePath: relativePath,
|
||||||
|
oldLines: [],
|
||||||
|
newLines: lines,
|
||||||
|
startLine: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"File creation cancelled by user",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirPath = path.dirname(absolutePath)
|
||||||
|
await fs.mkdir(dirPath, { recursive: true })
|
||||||
|
await fs.writeFile(absolutePath, content, "utf-8")
|
||||||
|
|
||||||
|
const stats = await fs.stat(absolutePath)
|
||||||
|
const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs)
|
||||||
|
await ctx.storage.setFile(relativePath, fileData)
|
||||||
|
|
||||||
|
const result: CreateFileResult = {
|
||||||
|
path: relativePath,
|
||||||
|
lines: lines.length,
|
||||||
|
size: stats.size,
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file exists.
|
||||||
|
*/
|
||||||
|
private async fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts
Normal file
136
packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from delete_file tool.
|
||||||
|
*/
|
||||||
|
export interface DeleteFileResult {
|
||||||
|
path: string
|
||||||
|
deleted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for deleting files.
|
||||||
|
* Deletes a file from the filesystem and storage.
|
||||||
|
* Requires user confirmation before deleting.
|
||||||
|
*/
|
||||||
|
export class DeleteFileTool implements ITool {
|
||||||
|
readonly name = "delete_file"
|
||||||
|
readonly description =
|
||||||
|
"Delete a file from the project. " +
|
||||||
|
"The file path must be within the project root. " +
|
||||||
|
"Requires confirmation before deleting."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = true
|
||||||
|
readonly category = "edit" as const
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||||
|
return "Parameter 'path' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = params.path as string
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await this.fileExists(absolutePath)
|
||||||
|
if (!exists) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`File not found: ${relativePath}`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileContent = await this.getFileContent(absolutePath, relativePath, ctx)
|
||||||
|
|
||||||
|
const confirmed = await ctx.requestConfirmation(`Delete file: ${relativePath}`, {
|
||||||
|
filePath: relativePath,
|
||||||
|
oldLines: fileContent,
|
||||||
|
newLines: [],
|
||||||
|
startLine: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"File deletion cancelled by user",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.unlink(absolutePath)
|
||||||
|
|
||||||
|
await ctx.storage.deleteFile(relativePath)
|
||||||
|
await ctx.storage.deleteAST(relativePath)
|
||||||
|
await ctx.storage.deleteMeta(relativePath)
|
||||||
|
|
||||||
|
const result: DeleteFileResult = {
|
||||||
|
path: relativePath,
|
||||||
|
deleted: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file exists.
|
||||||
|
*/
|
||||||
|
private async fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(filePath)
|
||||||
|
return stats.isFile()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file content for diff display.
|
||||||
|
*/
|
||||||
|
private async getFileContent(
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const fileData = await ctx.storage.getFile(relativePath)
|
||||||
|
if (fileData) {
|
||||||
|
return fileData.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absolutePath, "utf-8")
|
||||||
|
return content.split("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
226
packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts
Normal file
226
packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
|
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { hashLines } from "../../../shared/utils/hash.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from edit_lines tool.
|
||||||
|
*/
|
||||||
|
export interface EditLinesResult {
|
||||||
|
path: string
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
linesReplaced: number
|
||||||
|
linesInserted: number
|
||||||
|
totalLines: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for editing specific lines in a file.
|
||||||
|
* Replaces lines from start to end with new content.
|
||||||
|
* Requires user confirmation before applying changes.
|
||||||
|
*/
|
||||||
|
export class EditLinesTool implements ITool {
|
||||||
|
readonly name = "edit_lines"
|
||||||
|
readonly description =
|
||||||
|
"Replace lines in a file. Replaces lines from start to end (inclusive) with new content. " +
|
||||||
|
"Requires confirmation before applying changes."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start",
|
||||||
|
type: "number",
|
||||||
|
description: "Start line number (1-based, inclusive)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "end",
|
||||||
|
type: "number",
|
||||||
|
description: "End line number (1-based, inclusive)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
type: "string",
|
||||||
|
description: "New content to insert (can be multi-line)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = true
|
||||||
|
readonly category = "edit" as const
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||||
|
return "Parameter 'path' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.start !== "number" || !Number.isInteger(params.start)) {
|
||||||
|
return "Parameter 'start' is required and must be an integer"
|
||||||
|
}
|
||||||
|
if (params.start < 1) {
|
||||||
|
return "Parameter 'start' must be >= 1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.end !== "number" || !Number.isInteger(params.end)) {
|
||||||
|
return "Parameter 'end' is required and must be an integer"
|
||||||
|
}
|
||||||
|
if (params.end < 1) {
|
||||||
|
return "Parameter 'end' must be >= 1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.start > params.end) {
|
||||||
|
return "Parameter 'start' must be <= 'end'"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.content !== "string") {
|
||||||
|
return "Parameter 'content' is required and must be a string"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = params.path as string
|
||||||
|
const startLine = params.start as number
|
||||||
|
const endLine = params.end as number
|
||||||
|
const newContent = params.content as string
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentLines = await this.getCurrentLines(absolutePath, relativePath, ctx)
|
||||||
|
const totalLines = currentLines.length
|
||||||
|
|
||||||
|
if (startLine > totalLines) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`Start line ${String(startLine)} exceeds file length (${String(totalLines)} lines)`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedEnd = Math.min(endLine, totalLines)
|
||||||
|
const conflictCheck = await this.checkHashConflict(relativePath, currentLines, ctx)
|
||||||
|
if (conflictCheck) {
|
||||||
|
return createErrorResult(callId, conflictCheck, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldLines = currentLines.slice(startLine - 1, adjustedEnd)
|
||||||
|
const newLines = newContent.split("\n")
|
||||||
|
|
||||||
|
const confirmed = await ctx.requestConfirmation(
|
||||||
|
`Replace lines ${String(startLine)}-${String(adjustedEnd)} in ${relativePath}`,
|
||||||
|
{
|
||||||
|
filePath: relativePath,
|
||||||
|
oldLines,
|
||||||
|
newLines,
|
||||||
|
startLine,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return createErrorResult(callId, "Edit cancelled by user", Date.now() - startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLines = [
|
||||||
|
...currentLines.slice(0, startLine - 1),
|
||||||
|
...newLines,
|
||||||
|
...currentLines.slice(adjustedEnd),
|
||||||
|
]
|
||||||
|
|
||||||
|
await this.applyChanges(absolutePath, relativePath, updatedLines, ctx)
|
||||||
|
|
||||||
|
const result: EditLinesResult = {
|
||||||
|
path: relativePath,
|
||||||
|
startLine,
|
||||||
|
endLine: adjustedEnd,
|
||||||
|
linesReplaced: adjustedEnd - startLine + 1,
|
||||||
|
linesInserted: newLines.length,
|
||||||
|
totalLines: updatedLines.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current file lines from storage or filesystem.
|
||||||
|
*/
|
||||||
|
private async getCurrentLines(
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const fileData = await ctx.storage.getFile(relativePath)
|
||||||
|
if (fileData) {
|
||||||
|
return fileData.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absolutePath, "utf-8")
|
||||||
|
return content.split("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file has changed since it was indexed.
|
||||||
|
* Returns error message if conflict detected, null otherwise.
|
||||||
|
*/
|
||||||
|
private async checkHashConflict(
|
||||||
|
relativePath: string,
|
||||||
|
currentLines: string[],
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const storedFile = await ctx.storage.getFile(relativePath)
|
||||||
|
if (!storedFile) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHash = hashLines(currentLines)
|
||||||
|
if (storedFile.hash !== currentHash) {
|
||||||
|
return "File has been modified externally. Please refresh the file before editing."
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply changes to filesystem and storage.
|
||||||
|
*/
|
||||||
|
private async applyChanges(
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
lines: string[],
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const content = lines.join("\n")
|
||||||
|
await fs.writeFile(absolutePath, content, "utf-8")
|
||||||
|
|
||||||
|
const stats = await fs.stat(absolutePath)
|
||||||
|
const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs)
|
||||||
|
await ctx.storage.setFile(relativePath, fileData)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/ipuaro/src/infrastructure/tools/edit/index.ts
Normal file
4
packages/ipuaro/src/infrastructure/tools/edit/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Edit tools exports
|
||||||
|
export { EditLinesTool, type EditLinesResult } from "./EditLinesTool.js"
|
||||||
|
export { CreateFileTool, type CreateFileResult } from "./CreateFileTool.js"
|
||||||
|
export { DeleteFileTool, type DeleteFileResult } from "./DeleteFileTool.js"
|
||||||
29
packages/ipuaro/src/infrastructure/tools/index.ts
Normal file
29
packages/ipuaro/src/infrastructure/tools/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Tools module exports
|
||||||
|
export { ToolRegistry } from "./registry.js"
|
||||||
|
|
||||||
|
// Read tools
|
||||||
|
export { GetLinesTool, type GetLinesResult } from "./read/GetLinesTool.js"
|
||||||
|
export { GetFunctionTool, type GetFunctionResult } from "./read/GetFunctionTool.js"
|
||||||
|
export { GetClassTool, type GetClassResult } from "./read/GetClassTool.js"
|
||||||
|
export {
|
||||||
|
GetStructureTool,
|
||||||
|
type GetStructureResult,
|
||||||
|
type TreeNode,
|
||||||
|
} from "./read/GetStructureTool.js"
|
||||||
|
|
||||||
|
// Edit tools
|
||||||
|
export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js"
|
||||||
|
export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js"
|
||||||
|
export { DeleteFileTool, type DeleteFileResult } from "./edit/DeleteFileTool.js"
|
||||||
|
|
||||||
|
// Search tools
|
||||||
|
export {
|
||||||
|
FindReferencesTool,
|
||||||
|
type FindReferencesResult,
|
||||||
|
type SymbolReference,
|
||||||
|
} from "./search/FindReferencesTool.js"
|
||||||
|
export {
|
||||||
|
FindDefinitionTool,
|
||||||
|
type FindDefinitionResult,
|
||||||
|
type DefinitionLocation,
|
||||||
|
} from "./search/FindDefinitionTool.js"
|
||||||
165
packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts
Normal file
165
packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
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 { ClassInfo } from "../../../domain/value-objects/FileAST.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from get_class tool.
|
||||||
|
*/
|
||||||
|
export interface GetClassResult {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
isExported: boolean
|
||||||
|
isAbstract: boolean
|
||||||
|
extends?: string
|
||||||
|
implements: string[]
|
||||||
|
methods: string[]
|
||||||
|
properties: string[]
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for retrieving a class's source code by name.
|
||||||
|
* Uses AST to find exact line range.
|
||||||
|
*/
|
||||||
|
export class GetClassTool implements ITool {
|
||||||
|
readonly name = "get_class"
|
||||||
|
readonly description =
|
||||||
|
"Get a class's source code by name. Uses AST to find exact line range. " +
|
||||||
|
"Returns the class code with line numbers."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "string",
|
||||||
|
description: "Class name to retrieve",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = false
|
||||||
|
readonly category = "read" as const
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||||
|
return "Parameter 'path' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.name !== "string" || params.name.trim() === "") {
|
||||||
|
return "Parameter 'name' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = params.path as string
|
||||||
|
const className = params.name as string
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ast = await ctx.storage.getAST(relativePath)
|
||||||
|
if (!ast) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`AST not found for "${relativePath}". File may not be indexed.`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const classInfo = this.findClass(ast.classes, className)
|
||||||
|
if (!classInfo) {
|
||||||
|
const available = ast.classes.map((c) => c.name).join(", ") || "none"
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`Class "${className}" not found in "${relativePath}". Available: ${available}`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = await this.getFileLines(absolutePath, relativePath, ctx)
|
||||||
|
const classLines = lines.slice(classInfo.lineStart - 1, classInfo.lineEnd)
|
||||||
|
const content = this.formatLinesWithNumbers(classLines, classInfo.lineStart)
|
||||||
|
|
||||||
|
const result: GetClassResult = {
|
||||||
|
path: relativePath,
|
||||||
|
name: classInfo.name,
|
||||||
|
startLine: classInfo.lineStart,
|
||||||
|
endLine: classInfo.lineEnd,
|
||||||
|
isExported: classInfo.isExported,
|
||||||
|
isAbstract: classInfo.isAbstract,
|
||||||
|
extends: classInfo.extends,
|
||||||
|
implements: classInfo.implements,
|
||||||
|
methods: classInfo.methods.map((m) => m.name),
|
||||||
|
properties: classInfo.properties.map((p) => p.name),
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find class by name in AST.
|
||||||
|
*/
|
||||||
|
private findClass(classes: ClassInfo[], name: string): ClassInfo | undefined {
|
||||||
|
return classes.find((c) => c.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file lines from storage or filesystem.
|
||||||
|
*/
|
||||||
|
private async getFileLines(
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const fileData = await ctx.storage.getFile(relativePath)
|
||||||
|
if (fileData) {
|
||||||
|
return fileData.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absolutePath, "utf-8")
|
||||||
|
return content.split("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format lines with line numbers.
|
||||||
|
*/
|
||||||
|
private formatLinesWithNumbers(lines: string[], startLine: number): string {
|
||||||
|
const maxLineNum = startLine + lines.length - 1
|
||||||
|
const padWidth = String(maxLineNum).length
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const lineNum = String(startLine + index).padStart(padWidth, " ")
|
||||||
|
return `${lineNum}│${line}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
161
packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts
Normal file
161
packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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 { FunctionInfo } from "../../../domain/value-objects/FileAST.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from get_function tool.
|
||||||
|
*/
|
||||||
|
export interface GetFunctionResult {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
isAsync: boolean
|
||||||
|
isExported: boolean
|
||||||
|
params: string[]
|
||||||
|
returnType?: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for retrieving a function's source code by name.
|
||||||
|
* Uses AST to find exact line range.
|
||||||
|
*/
|
||||||
|
export class GetFunctionTool implements ITool {
|
||||||
|
readonly name = "get_function"
|
||||||
|
readonly description =
|
||||||
|
"Get a function's source code by name. Uses AST to find exact line range. " +
|
||||||
|
"Returns the function code with line numbers."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "string",
|
||||||
|
description: "Function name to retrieve",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = false
|
||||||
|
readonly category = "read" as const
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||||
|
return "Parameter 'path' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.name !== "string" || params.name.trim() === "") {
|
||||||
|
return "Parameter 'name' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = params.path as string
|
||||||
|
const functionName = params.name as string
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ast = await ctx.storage.getAST(relativePath)
|
||||||
|
if (!ast) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`AST not found for "${relativePath}". File may not be indexed.`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionInfo = this.findFunction(ast.functions, functionName)
|
||||||
|
if (!functionInfo) {
|
||||||
|
const available = ast.functions.map((f) => f.name).join(", ") || "none"
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`Function "${functionName}" not found in "${relativePath}". Available: ${available}`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = await this.getFileLines(absolutePath, relativePath, ctx)
|
||||||
|
const functionLines = lines.slice(functionInfo.lineStart - 1, functionInfo.lineEnd)
|
||||||
|
const content = this.formatLinesWithNumbers(functionLines, functionInfo.lineStart)
|
||||||
|
|
||||||
|
const result: GetFunctionResult = {
|
||||||
|
path: relativePath,
|
||||||
|
name: functionInfo.name,
|
||||||
|
startLine: functionInfo.lineStart,
|
||||||
|
endLine: functionInfo.lineEnd,
|
||||||
|
isAsync: functionInfo.isAsync,
|
||||||
|
isExported: functionInfo.isExported,
|
||||||
|
params: functionInfo.params.map((p) => p.name),
|
||||||
|
returnType: functionInfo.returnType,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find function by name in AST.
|
||||||
|
*/
|
||||||
|
private findFunction(functions: FunctionInfo[], name: string): FunctionInfo | undefined {
|
||||||
|
return functions.find((f) => f.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file lines from storage or filesystem.
|
||||||
|
*/
|
||||||
|
private async getFileLines(
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const fileData = await ctx.storage.getFile(relativePath)
|
||||||
|
if (fileData) {
|
||||||
|
return fileData.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absolutePath, "utf-8")
|
||||||
|
return content.split("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format lines with line numbers.
|
||||||
|
*/
|
||||||
|
private formatLinesWithNumbers(lines: string[], startLine: number): string {
|
||||||
|
const maxLineNum = startLine + lines.length - 1
|
||||||
|
const padWidth = String(maxLineNum).length
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const lineNum = String(startLine + index).padStart(padWidth, " ")
|
||||||
|
return `${lineNum}│${line}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
158
packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts
Normal file
158
packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from get_lines tool.
|
||||||
|
*/
|
||||||
|
export interface GetLinesResult {
|
||||||
|
path: string
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
totalLines: number
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for reading specific lines from a file.
|
||||||
|
* Returns content with line numbers.
|
||||||
|
*/
|
||||||
|
export class GetLinesTool implements ITool {
|
||||||
|
readonly name = "get_lines"
|
||||||
|
readonly description =
|
||||||
|
"Get specific lines from a file. Returns the content with line numbers. " +
|
||||||
|
"If no range is specified, returns the entire file."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path relative to project root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start",
|
||||||
|
type: "number",
|
||||||
|
description: "Start line number (1-based, inclusive)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "end",
|
||||||
|
type: "number",
|
||||||
|
description: "End line number (1-based, inclusive)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = false
|
||||||
|
readonly category = "read" as const
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||||
|
return "Parameter 'path' is required and must be a non-empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.start !== undefined) {
|
||||||
|
if (typeof params.start !== "number" || !Number.isInteger(params.start)) {
|
||||||
|
return "Parameter 'start' must be an integer"
|
||||||
|
}
|
||||||
|
if (params.start < 1) {
|
||||||
|
return "Parameter 'start' must be >= 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.end !== undefined) {
|
||||||
|
if (typeof params.end !== "number" || !Number.isInteger(params.end)) {
|
||||||
|
return "Parameter 'end' must be an integer"
|
||||||
|
}
|
||||||
|
if (params.end < 1) {
|
||||||
|
return "Parameter 'end' must be >= 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.start !== undefined && params.end !== undefined && params.start > params.end) {
|
||||||
|
return "Parameter 'start' must be <= 'end'"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = params.path as string
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = await this.getFileLines(absolutePath, relativePath, ctx)
|
||||||
|
const totalLines = lines.length
|
||||||
|
|
||||||
|
let startLine = (params.start as number | undefined) ?? 1
|
||||||
|
let endLine = (params.end as number | undefined) ?? totalLines
|
||||||
|
|
||||||
|
startLine = Math.max(1, Math.min(startLine, totalLines))
|
||||||
|
endLine = Math.max(startLine, Math.min(endLine, totalLines))
|
||||||
|
|
||||||
|
const selectedLines = lines.slice(startLine - 1, endLine)
|
||||||
|
const content = this.formatLinesWithNumbers(selectedLines, startLine)
|
||||||
|
|
||||||
|
const result: GetLinesResult = {
|
||||||
|
path: relativePath,
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
totalLines,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 file lines from storage or filesystem.
|
||||||
|
*/
|
||||||
|
private async getFileLines(
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const fileData = await ctx.storage.getFile(relativePath)
|
||||||
|
if (fileData) {
|
||||||
|
return fileData.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(absolutePath, "utf-8")
|
||||||
|
return content.split("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format lines with line numbers.
|
||||||
|
* Example: " 1│const x = 1"
|
||||||
|
*/
|
||||||
|
private formatLinesWithNumbers(lines: string[], startLine: number): string {
|
||||||
|
const maxLineNum = startLine + lines.length - 1
|
||||||
|
const padWidth = String(maxLineNum).length
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const lineNum = String(startLine + index).padStart(padWidth, " ")
|
||||||
|
return `${lineNum}│${line}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
|
import {
|
||||||
|
createErrorResult,
|
||||||
|
createSuccessResult,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree node representing a file or directory.
|
||||||
|
*/
|
||||||
|
export interface TreeNode {
|
||||||
|
name: string
|
||||||
|
type: "file" | "directory"
|
||||||
|
children?: TreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data from get_structure tool.
|
||||||
|
*/
|
||||||
|
export interface GetStructureResult {
|
||||||
|
path: string
|
||||||
|
tree: TreeNode
|
||||||
|
content: string
|
||||||
|
stats: {
|
||||||
|
directories: number
|
||||||
|
files: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool for getting project directory structure as a tree.
|
||||||
|
*/
|
||||||
|
export class GetStructureTool implements ITool {
|
||||||
|
readonly name = "get_structure"
|
||||||
|
readonly description =
|
||||||
|
"Get project directory structure as a tree. " +
|
||||||
|
"If path is specified, shows structure of that subdirectory only."
|
||||||
|
readonly parameters: ToolParameterSchema[] = [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "Subdirectory path relative to project root (optional, defaults to root)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "depth",
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum depth to traverse (default: unlimited)",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
readonly requiresConfirmation = false
|
||||||
|
readonly category = "read" as const
|
||||||
|
|
||||||
|
private readonly defaultIgnorePatterns = new Set([
|
||||||
|
...DEFAULT_IGNORE_PATTERNS,
|
||||||
|
".git",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
"__pycache__",
|
||||||
|
".pytest_cache",
|
||||||
|
".nyc_output",
|
||||||
|
"coverage",
|
||||||
|
])
|
||||||
|
|
||||||
|
validateParams(params: Record<string, unknown>): string | null {
|
||||||
|
if (params.path !== undefined) {
|
||||||
|
if (typeof params.path !== "string") {
|
||||||
|
return "Parameter 'path' must be a string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.depth !== undefined) {
|
||||||
|
if (typeof params.depth !== "number" || !Number.isInteger(params.depth)) {
|
||||||
|
return "Parameter 'depth' must be an integer"
|
||||||
|
}
|
||||||
|
if (params.depth < 1) {
|
||||||
|
return "Parameter 'depth' must be >= 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const relativePath = (params.path as string | undefined) ?? ""
|
||||||
|
const maxDepth = params.depth as number | undefined
|
||||||
|
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
"Path must be within project root",
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(absolutePath)
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return createErrorResult(
|
||||||
|
callId,
|
||||||
|
`Path "${relativePath}" is not a directory`,
|
||||||
|
Date.now() - startTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = { directories: 0, files: 0 }
|
||||||
|
const tree = await this.buildTree(absolutePath, maxDepth, 0, stats)
|
||||||
|
const content = this.formatTree(tree)
|
||||||
|
|
||||||
|
const result: GetStructureResult = {
|
||||||
|
path: relativePath || ".",
|
||||||
|
tree,
|
||||||
|
content,
|
||||||
|
stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build tree structure recursively.
|
||||||
|
*/
|
||||||
|
private async buildTree(
|
||||||
|
dirPath: string,
|
||||||
|
maxDepth: number | undefined,
|
||||||
|
currentDepth: number,
|
||||||
|
stats: { directories: number; files: number },
|
||||||
|
): Promise<TreeNode> {
|
||||||
|
const name = path.basename(dirPath) || dirPath
|
||||||
|
const node: TreeNode = { name, type: "directory", children: [] }
|
||||||
|
stats.directories++
|
||||||
|
|
||||||
|
if (maxDepth !== undefined && currentDepth >= maxDepth) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||||
|
const sortedEntries = entries
|
||||||
|
.filter((e) => !this.shouldIgnore(e.name))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDirectory() && !b.isDirectory()) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (!a.isDirectory() && b.isDirectory()) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const entry of sortedEntries) {
|
||||||
|
const entryPath = path.join(dirPath, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const childNode = await this.buildTree(entryPath, maxDepth, currentDepth + 1, stats)
|
||||||
|
node.children?.push(childNode)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
node.children?.push({ name: entry.name, type: "file" })
|
||||||
|
stats.files++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if entry should be ignored.
|
||||||
|
*/
|
||||||
|
private shouldIgnore(name: string): boolean {
|
||||||
|
return this.defaultIgnorePatterns.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tree as ASCII art.
|
||||||
|
*/
|
||||||
|
private formatTree(node: TreeNode, prefix = "", isLast = true): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
const connector = isLast ? "└── " : "├── "
|
||||||
|
const icon = node.type === "directory" ? "📁 " : "📄 "
|
||||||
|
|
||||||
|
lines.push(`${prefix}${connector}${icon}${node.name}`)
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const childPrefix = prefix + (isLast ? " " : "│ ")
|
||||||
|
const childCount = node.children.length
|
||||||
|
node.children.forEach((child, index) => {
|
||||||
|
const childIsLast = index === childCount - 1
|
||||||
|
lines.push(this.formatTree(child, childPrefix, childIsLast))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
190
packages/ipuaro/src/infrastructure/tools/registry.ts
Normal file
190
packages/ipuaro/src/infrastructure/tools/registry.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
|
||||||
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../domain/services/ITool.js"
|
||||||
|
import { createErrorResult, type ToolResult } from "../../domain/value-objects/ToolResult.js"
|
||||||
|
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool registry implementation.
|
||||||
|
* Manages registration and execution of tools.
|
||||||
|
*/
|
||||||
|
export class ToolRegistry implements IToolRegistry {
|
||||||
|
private readonly tools = new Map<string, ITool>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a tool.
|
||||||
|
* @throws IpuaroError if tool with same name already registered
|
||||||
|
*/
|
||||||
|
register(tool: ITool): void {
|
||||||
|
if (this.tools.has(tool.name)) {
|
||||||
|
throw new IpuaroError(
|
||||||
|
"validation",
|
||||||
|
`Tool "${tool.name}" is already registered`,
|
||||||
|
true,
|
||||||
|
"Use a different tool name or unregister the existing tool first",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.tools.set(tool.name, tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a tool by name.
|
||||||
|
* @returns true if tool was removed, false if not found
|
||||||
|
*/
|
||||||
|
unregister(name: string): boolean {
|
||||||
|
return this.tools.delete(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool by name.
|
||||||
|
*/
|
||||||
|
get(name: string): ITool | undefined {
|
||||||
|
return this.tools.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered tools.
|
||||||
|
*/
|
||||||
|
getAll(): ITool[] {
|
||||||
|
return Array.from(this.tools.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tools by category.
|
||||||
|
*/
|
||||||
|
getByCategory(category: ITool["category"]): ITool[] {
|
||||||
|
return this.getAll().filter((tool) => tool.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tool exists.
|
||||||
|
*/
|
||||||
|
has(name: string): boolean {
|
||||||
|
return this.tools.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of registered tools.
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.tools.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute tool by name.
|
||||||
|
* @throws IpuaroError if tool not found
|
||||||
|
*/
|
||||||
|
async execute(
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const callId = `${name}-${String(startTime)}`
|
||||||
|
|
||||||
|
const tool = this.tools.get(name)
|
||||||
|
if (!tool) {
|
||||||
|
return createErrorResult(callId, `Tool "${name}" not found`, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = tool.validateParams(params)
|
||||||
|
if (validationError) {
|
||||||
|
return createErrorResult(callId, validationError, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.requiresConfirmation) {
|
||||||
|
const confirmed = await ctx.requestConfirmation(
|
||||||
|
`Execute "${name}" with params: ${JSON.stringify(params)}`,
|
||||||
|
)
|
||||||
|
if (!confirmed) {
|
||||||
|
return createErrorResult(callId, "User cancelled operation", Date.now() - startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tool.execute(params, ctx)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
callId,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool definitions for LLM.
|
||||||
|
* Converts ITool[] to LLM-compatible format.
|
||||||
|
*/
|
||||||
|
getToolDefinitions(): {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
parameters: {
|
||||||
|
type: "object"
|
||||||
|
properties: Record<string, { type: string; description: string }>
|
||||||
|
required: string[]
|
||||||
|
}
|
||||||
|
}[] {
|
||||||
|
return this.getAll().map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: this.convertParametersToSchema(tool.parameters),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert tool parameters to JSON Schema format.
|
||||||
|
*/
|
||||||
|
private convertParametersToSchema(params: ToolParameterSchema[]): {
|
||||||
|
type: "object"
|
||||||
|
properties: Record<string, { type: string; description: string }>
|
||||||
|
required: string[]
|
||||||
|
} {
|
||||||
|
const properties: Record<string, { type: string; description: string }> = {}
|
||||||
|
const required: string[] = []
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
properties[param.name] = {
|
||||||
|
type: param.type,
|
||||||
|
description: param.description,
|
||||||
|
}
|
||||||
|
if (param.required) {
|
||||||
|
required.push(param.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
properties,
|
||||||
|
required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all registered tools.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.tools.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool names.
|
||||||
|
*/
|
||||||
|
getNames(): string[] {
|
||||||
|
return Array.from(this.tools.keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tools that require confirmation.
|
||||||
|
*/
|
||||||
|
getConfirmationTools(): ITool[] {
|
||||||
|
return this.getAll().filter((tool) => tool.requiresConfirmation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tools that don't require confirmation.
|
||||||
|
*/
|
||||||
|
getSafeTools(): ITool[] {
|
||||||
|
return this.getAll().filter((tool) => !tool.requiresConfirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -63,6 +63,13 @@ describe("ChatMessage", () => {
|
|||||||
|
|
||||||
expect(msg.content).toContain("[2] Error: Not found")
|
expect(msg.content).toContain("[2] Error: Not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should handle error result without error message", () => {
|
||||||
|
const results = [{ callId: "3", success: false, executionTimeMs: 5 }]
|
||||||
|
const msg = createToolMessage(results)
|
||||||
|
|
||||||
|
expect(msg.content).toContain("[3] Error: Unknown error")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createSystemMessage", () => {
|
describe("createSystemMessage", () => {
|
||||||
|
|||||||
@@ -301,6 +301,66 @@ describe("ASTParser", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("import string formats", () => {
|
||||||
|
it("should handle single-quoted imports", () => {
|
||||||
|
const code = `import { foo } from './module'`
|
||||||
|
const ast = parser.parse(code, "ts")
|
||||||
|
|
||||||
|
expect(ast.imports).toHaveLength(1)
|
||||||
|
expect(ast.imports[0].from).toBe("./module")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle double-quoted imports", () => {
|
||||||
|
const code = `import { bar } from "./other"`
|
||||||
|
const ast = parser.parse(code, "ts")
|
||||||
|
|
||||||
|
expect(ast.imports).toHaveLength(1)
|
||||||
|
expect(ast.imports[0].from).toBe("./other")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("parameter types", () => {
|
||||||
|
it("should handle simple identifier parameters", () => {
|
||||||
|
const code = `const fn = (x) => x * 2`
|
||||||
|
const ast = parser.parse(code, "ts")
|
||||||
|
|
||||||
|
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle optional parameters with defaults", () => {
|
||||||
|
const code = `function greet(name: string = "World"): string { return name }`
|
||||||
|
const ast = parser.parse(code, "ts")
|
||||||
|
|
||||||
|
expect(ast.functions).toHaveLength(1)
|
||||||
|
const fn = ast.functions[0]
|
||||||
|
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle arrow function with untyped params", () => {
|
||||||
|
const code = `const add = (a, b) => a + b`
|
||||||
|
const ast = parser.parse(code, "ts")
|
||||||
|
|
||||||
|
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple parameter types", () => {
|
||||||
|
const code = `
|
||||||
|
function mix(
|
||||||
|
required: string,
|
||||||
|
optional?: number,
|
||||||
|
withDefault: boolean = true
|
||||||
|
) {}
|
||||||
|
`
|
||||||
|
const ast = parser.parse(code, "ts")
|
||||||
|
|
||||||
|
expect(ast.functions).toHaveLength(1)
|
||||||
|
const fn = ast.functions[0]
|
||||||
|
expect(fn.params).toHaveLength(3)
|
||||||
|
expect(fn.params.some((p) => p.optional)).toBe(true)
|
||||||
|
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("complex file", () => {
|
describe("complex file", () => {
|
||||||
it("should parse complex TypeScript file", () => {
|
it("should parse complex TypeScript file", () => {
|
||||||
const code = `
|
const code = `
|
||||||
|
|||||||
@@ -212,6 +212,32 @@ describe("FileScanner", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("empty file handling", () => {
|
||||||
|
it("should consider empty files as text files", async () => {
|
||||||
|
const emptyFile = path.join(FIXTURES_DIR, "empty-file.ts")
|
||||||
|
await fs.writeFile(emptyFile, "")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isText = await FileScanner.isTextFile(emptyFile)
|
||||||
|
expect(isText).toBe(true)
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(emptyFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should read empty file content", async () => {
|
||||||
|
const emptyFile = path.join(FIXTURES_DIR, "empty-content.ts")
|
||||||
|
await fs.writeFile(emptyFile, "")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await FileScanner.readFileContent(emptyFile)
|
||||||
|
expect(content).toBe("")
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(emptyFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("empty directory handling", () => {
|
describe("empty directory handling", () => {
|
||||||
let emptyDir: string
|
let emptyDir: string
|
||||||
|
|
||||||
|
|||||||
@@ -605,4 +605,44 @@ export type ServiceResult<T> = { success: true; data: T } | { success: false; er
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("jsx to tsx resolution", () => {
|
||||||
|
it("should resolve .jsx imports to .tsx files", () => {
|
||||||
|
const mainCode = `import { Button } from "./Button.jsx"`
|
||||||
|
const buttonCode = `export function Button() { return null }`
|
||||||
|
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
["/project/src/main.ts", parser.parse(mainCode, "ts")],
|
||||||
|
["/project/src/Button.tsx", parser.parse(buttonCode, "tsx")],
|
||||||
|
])
|
||||||
|
|
||||||
|
const graph = builder.buildDepsGraph(asts)
|
||||||
|
|
||||||
|
expect(graph.imports.get("/project/src/main.ts")).toContain("/project/src/Button.tsx")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty deps graph for circular dependencies", () => {
|
||||||
|
const graph = {
|
||||||
|
imports: new Map<string, string[]>(),
|
||||||
|
importedBy: new Map<string, string[]>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycles = builder.findCircularDependencies(graph)
|
||||||
|
expect(cycles).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle single file with no imports", () => {
|
||||||
|
const code = `export const x = 1`
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
["/project/src/single.ts", parser.parse(code, "ts")],
|
||||||
|
])
|
||||||
|
|
||||||
|
const graph = builder.buildDepsGraph(asts)
|
||||||
|
const cycles = builder.findCircularDependencies(graph)
|
||||||
|
|
||||||
|
expect(cycles).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -544,6 +544,44 @@ const b = 2`
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("dependency resolution with different extensions", () => {
|
||||||
|
it("should resolve imports from index files", () => {
|
||||||
|
const content = `import { utils } from "./utils/index"`
|
||||||
|
const ast = parser.parse(content, "ts")
|
||||||
|
const allASTs = new Map<string, FileAST>()
|
||||||
|
allASTs.set("/project/src/main.ts", ast)
|
||||||
|
allASTs.set("/project/src/utils/index.ts", createEmptyFileAST())
|
||||||
|
|
||||||
|
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
|
||||||
|
|
||||||
|
expect(meta.dependencies).toContain("/project/src/utils/index.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert .js extension to .ts when resolving", () => {
|
||||||
|
const content = `import { helper } from "./helper.js"`
|
||||||
|
const ast = parser.parse(content, "ts")
|
||||||
|
const allASTs = new Map<string, FileAST>()
|
||||||
|
allASTs.set("/project/src/main.ts", ast)
|
||||||
|
allASTs.set("/project/src/helper.ts", createEmptyFileAST())
|
||||||
|
|
||||||
|
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
|
||||||
|
|
||||||
|
expect(meta.dependencies).toContain("/project/src/helper.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert .jsx extension to .tsx when resolving", () => {
|
||||||
|
const content = `import { Button } from "./Button.jsx"`
|
||||||
|
const ast = parser.parse(content, "ts")
|
||||||
|
const allASTs = new Map<string, FileAST>()
|
||||||
|
allASTs.set("/project/src/App.tsx", ast)
|
||||||
|
allASTs.set("/project/src/Button.tsx", createEmptyFileAST())
|
||||||
|
|
||||||
|
const meta = analyzer.analyze("/project/src/App.tsx", ast, content, allASTs)
|
||||||
|
|
||||||
|
expect(meta.dependencies).toContain("/project/src/Button.tsx")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("analyze", () => {
|
describe("analyze", () => {
|
||||||
it("should produce complete FileMeta", () => {
|
it("should produce complete FileMeta", () => {
|
||||||
const content = `import { helper } from "./helper"
|
const content = `import { helper } from "./helper"
|
||||||
|
|||||||
@@ -94,12 +94,70 @@ describe("Watchdog", () => {
|
|||||||
it("should return empty array when not watching", () => {
|
it("should return empty array when not watching", () => {
|
||||||
expect(watchdog.getWatchedPaths()).toEqual([])
|
expect(watchdog.getWatchedPaths()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should return paths when watching", async () => {
|
||||||
|
const testFile = path.join(tempDir, "exists.ts")
|
||||||
|
await fs.writeFile(testFile, "const x = 1")
|
||||||
|
|
||||||
|
watchdog.start(tempDir)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
|
||||||
|
const paths = watchdog.getWatchedPaths()
|
||||||
|
expect(Array.isArray(paths)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("flushAll", () => {
|
describe("flushAll", () => {
|
||||||
it("should not throw when no pending changes", () => {
|
it("should not throw when no pending changes", () => {
|
||||||
expect(() => watchdog.flushAll()).not.toThrow()
|
expect(() => watchdog.flushAll()).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should flush all pending changes", async () => {
|
||||||
|
const events: FileChangeEvent[] = []
|
||||||
|
watchdog.onFileChange((event) => events.push(event))
|
||||||
|
watchdog.start(tempDir)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
const testFile = path.join(tempDir, "flush-test.ts")
|
||||||
|
await fs.writeFile(testFile, "const x = 1")
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
|
||||||
|
watchdog.flushAll()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("ignore patterns", () => {
|
||||||
|
it("should handle glob patterns with wildcards", async () => {
|
||||||
|
const customWatchdog = new Watchdog({
|
||||||
|
debounceMs: 50,
|
||||||
|
ignorePatterns: ["*.log", "**/*.tmp"],
|
||||||
|
})
|
||||||
|
|
||||||
|
customWatchdog.start(tempDir)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
expect(customWatchdog.isWatching()).toBe(true)
|
||||||
|
|
||||||
|
await customWatchdog.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle simple directory patterns", async () => {
|
||||||
|
const customWatchdog = new Watchdog({
|
||||||
|
debounceMs: 50,
|
||||||
|
ignorePatterns: ["node_modules", "dist"],
|
||||||
|
})
|
||||||
|
|
||||||
|
customWatchdog.start(tempDir)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
expect(customWatchdog.isWatching()).toBe(true)
|
||||||
|
|
||||||
|
await customWatchdog.stop()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("file change detection", () => {
|
describe("file change detection", () => {
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import type { LLMConfig } from "../../../../src/shared/constants/config.js"
|
||||||
|
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||||
|
import { createUserMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||||
|
|
||||||
|
const mockChatResponse = {
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "This is a test response.",
|
||||||
|
tool_calls: undefined,
|
||||||
|
},
|
||||||
|
eval_count: 50,
|
||||||
|
done_reason: "stop",
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockListResponse = {
|
||||||
|
models: [
|
||||||
|
{ name: "qwen2.5-coder:7b-instruct", size: 4000000000 },
|
||||||
|
{ name: "llama2:latest", size: 3500000000 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockOllamaInstance = {
|
||||||
|
chat: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
pull: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("ollama", () => {
|
||||||
|
return {
|
||||||
|
Ollama: vi.fn(() => mockOllamaInstance),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { OllamaClient } = await import("../../../../src/infrastructure/llm/OllamaClient.js")
|
||||||
|
|
||||||
|
describe("OllamaClient", () => {
|
||||||
|
const defaultConfig: LLMConfig = {
|
||||||
|
model: "qwen2.5-coder:7b-instruct",
|
||||||
|
contextWindow: 128000,
|
||||||
|
temperature: 0.1,
|
||||||
|
host: "http://localhost:11434",
|
||||||
|
timeout: 120000,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockOllamaInstance.chat.mockResolvedValue(mockChatResponse)
|
||||||
|
mockOllamaInstance.list.mockResolvedValue(mockListResponse)
|
||||||
|
mockOllamaInstance.pull.mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should create instance with config", () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
expect(client).toBeDefined()
|
||||||
|
expect(client.getModelName()).toBe("qwen2.5-coder:7b-instruct")
|
||||||
|
expect(client.getContextWindowSize()).toBe(128000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("chat", () => {
|
||||||
|
it("should send messages and return response", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [createUserMessage("Hello, world!")]
|
||||||
|
|
||||||
|
const response = await client.chat(messages)
|
||||||
|
|
||||||
|
expect(response.content).toBe("This is a test response.")
|
||||||
|
expect(response.tokens).toBe(50)
|
||||||
|
expect(response.stopReason).toBe("end")
|
||||||
|
expect(response.truncated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert messages to Ollama format", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [createUserMessage("Hello")]
|
||||||
|
|
||||||
|
await client.chat(messages)
|
||||||
|
|
||||||
|
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: "qwen2.5-coder:7b-instruct",
|
||||||
|
messages: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should pass tools when provided", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [createUserMessage("Read file")]
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: "get_lines",
|
||||||
|
description: "Get lines from file",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string" as const,
|
||||||
|
description: "File path",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await client.chat(messages, tools)
|
||||||
|
|
||||||
|
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tools: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "function",
|
||||||
|
function: expect.objectContaining({
|
||||||
|
name: "get_lines",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract tool calls from response", async () => {
|
||||||
|
mockOllamaInstance.chat.mockResolvedValue({
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
name: "get_lines",
|
||||||
|
arguments: { path: "src/index.ts" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
eval_count: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const response = await client.chat([createUserMessage("Read file")])
|
||||||
|
|
||||||
|
expect(response.toolCalls).toHaveLength(1)
|
||||||
|
expect(response.toolCalls[0].name).toBe("get_lines")
|
||||||
|
expect(response.toolCalls[0].params).toEqual({ path: "src/index.ts" })
|
||||||
|
expect(response.stopReason).toBe("tool_use")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle connection errors", async () => {
|
||||||
|
mockOllamaInstance.chat.mockRejectedValue(new Error("fetch failed"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(IpuaroError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle model not found errors", async () => {
|
||||||
|
mockOllamaInstance.chat.mockRejectedValue(new Error("model not found"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(/not found/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("countTokens", () => {
|
||||||
|
it("should estimate tokens for text", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const count = await client.countTokens("Hello, world!")
|
||||||
|
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
expect(typeof count).toBe("number")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isAvailable", () => {
|
||||||
|
it("should return true when Ollama is available", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const available = await client.isAvailable()
|
||||||
|
|
||||||
|
expect(available).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false when Ollama is not available", async () => {
|
||||||
|
mockOllamaInstance.list.mockRejectedValue(new Error("Connection refused"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const available = await client.isAvailable()
|
||||||
|
|
||||||
|
expect(available).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getModelName", () => {
|
||||||
|
it("should return configured model name", () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
expect(client.getModelName()).toBe("qwen2.5-coder:7b-instruct")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getContextWindowSize", () => {
|
||||||
|
it("should return configured context window size", () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
expect(client.getContextWindowSize()).toBe(128000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pullModel", () => {
|
||||||
|
it("should pull model successfully", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.pullModel("llama2")).resolves.toBeUndefined()
|
||||||
|
expect(mockOllamaInstance.pull).toHaveBeenCalledWith({
|
||||||
|
model: "llama2",
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw on pull failure", async () => {
|
||||||
|
mockOllamaInstance.pull.mockRejectedValue(new Error("Network error"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.pullModel("llama2")).rejects.toThrow(IpuaroError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasModel", () => {
|
||||||
|
it("should return true for available model", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const has = await client.hasModel("qwen2.5-coder:7b-instruct")
|
||||||
|
|
||||||
|
expect(has).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true for model prefix", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const has = await client.hasModel("llama2")
|
||||||
|
|
||||||
|
expect(has).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for missing model", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const has = await client.hasModel("unknown-model")
|
||||||
|
|
||||||
|
expect(has).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false when list fails", async () => {
|
||||||
|
mockOllamaInstance.list.mockRejectedValue(new Error("Error"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const has = await client.hasModel("any-model")
|
||||||
|
|
||||||
|
expect(has).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("listModels", () => {
|
||||||
|
it("should return list of model names", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
const models = await client.listModels()
|
||||||
|
|
||||||
|
expect(models).toContain("qwen2.5-coder:7b-instruct")
|
||||||
|
expect(models).toContain("llama2:latest")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw on list failure", async () => {
|
||||||
|
mockOllamaInstance.list.mockRejectedValue(new Error("Network error"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.listModels()).rejects.toThrow(IpuaroError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("abort", () => {
|
||||||
|
it("should not throw when no request is in progress", () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
expect(() => client.abort()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("message conversion", () => {
|
||||||
|
it("should convert system messages", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: "You are a helpful assistant",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await client.chat(messages)
|
||||||
|
|
||||||
|
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
messages: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "system",
|
||||||
|
content: "You are a helpful assistant",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert tool result messages", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "tool" as const,
|
||||||
|
content: '{"result": "success"}',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolResults: [
|
||||||
|
{ callId: "call_1", success: true, data: "success", executionTimeMs: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await client.chat(messages)
|
||||||
|
|
||||||
|
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
messages: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "tool",
|
||||||
|
content: '{"result": "success"}',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert assistant messages with tool calls", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "I will read the file",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolCalls: [{ id: "call_1", name: "get_lines", params: { path: "test.ts" } }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await client.chat(messages)
|
||||||
|
|
||||||
|
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
messages: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
content: "I will read the file",
|
||||||
|
tool_calls: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
function: expect.objectContaining({
|
||||||
|
name: "get_lines",
|
||||||
|
arguments: { path: "test.ts" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("response handling", () => {
|
||||||
|
it("should estimate tokens when eval_count is undefined", async () => {
|
||||||
|
mockOllamaInstance.chat.mockResolvedValue({
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hello world response",
|
||||||
|
tool_calls: undefined,
|
||||||
|
},
|
||||||
|
eval_count: undefined,
|
||||||
|
done_reason: "stop",
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const response = await client.chat([createUserMessage("Hello")])
|
||||||
|
|
||||||
|
expect(response.tokens).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return length stop reason", async () => {
|
||||||
|
mockOllamaInstance.chat.mockResolvedValue({
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Truncated...",
|
||||||
|
tool_calls: undefined,
|
||||||
|
},
|
||||||
|
eval_count: 100,
|
||||||
|
done_reason: "length",
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const response = await client.chat([createUserMessage("Hello")])
|
||||||
|
|
||||||
|
expect(response.stopReason).toBe("length")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tool parameter conversion", () => {
|
||||||
|
it("should include enum values when present", async () => {
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
const messages = [createUserMessage("Get status")]
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: "get_status",
|
||||||
|
description: "Get status",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Status type",
|
||||||
|
required: true,
|
||||||
|
enum: ["active", "inactive", "pending"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await client.chat(messages, tools)
|
||||||
|
|
||||||
|
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tools: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
function: expect.objectContaining({
|
||||||
|
parameters: expect.objectContaining({
|
||||||
|
properties: expect.objectContaining({
|
||||||
|
type: expect.objectContaining({
|
||||||
|
enum: ["active", "inactive", "pending"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("should handle ECONNREFUSED errors", async () => {
|
||||||
|
mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(
|
||||||
|
/Cannot connect to Ollama/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle generic errors with context", async () => {
|
||||||
|
mockOllamaInstance.pull.mockRejectedValue(new Error("Unknown error"))
|
||||||
|
|
||||||
|
const client = new OllamaClient(defaultConfig)
|
||||||
|
|
||||||
|
await expect(client.pullModel("test")).rejects.toThrow(/Failed to pull model/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
parseToolCalls,
|
||||||
|
formatToolCallsAsXml,
|
||||||
|
extractThinking,
|
||||||
|
hasToolCalls,
|
||||||
|
validateToolCallParams,
|
||||||
|
} from "../../../../src/infrastructure/llm/ResponseParser.js"
|
||||||
|
import { createToolCall } from "../../../../src/domain/value-objects/ToolCall.js"
|
||||||
|
|
||||||
|
describe("ResponseParser", () => {
|
||||||
|
describe("parseToolCalls", () => {
|
||||||
|
it("should parse a single tool call", () => {
|
||||||
|
const response = `<tool_call name="get_lines">
|
||||||
|
<path>src/index.ts</path>
|
||||||
|
<start>1</start>
|
||||||
|
<end>10</end>
|
||||||
|
</tool_call>`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(1)
|
||||||
|
expect(result.toolCalls[0].name).toBe("get_lines")
|
||||||
|
expect(result.toolCalls[0].params).toEqual({
|
||||||
|
path: "src/index.ts",
|
||||||
|
start: 1,
|
||||||
|
end: 10,
|
||||||
|
})
|
||||||
|
expect(result.hasParseErrors).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse multiple tool calls", () => {
|
||||||
|
const response = `
|
||||||
|
<tool_call name="get_lines">
|
||||||
|
<path>src/a.ts</path>
|
||||||
|
</tool_call>
|
||||||
|
<tool_call name="get_function">
|
||||||
|
<path>src/b.ts</path>
|
||||||
|
<name>myFunc</name>
|
||||||
|
</tool_call>
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(2)
|
||||||
|
expect(result.toolCalls[0].name).toBe("get_lines")
|
||||||
|
expect(result.toolCalls[1].name).toBe("get_function")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract text content without tool calls", () => {
|
||||||
|
const response = `Let me check the file.
|
||||||
|
<tool_call name="get_lines">
|
||||||
|
<path>src/index.ts</path>
|
||||||
|
</tool_call>
|
||||||
|
Here's what I found.`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.content).toContain("Let me check the file.")
|
||||||
|
expect(result.content).toContain("Here's what I found.")
|
||||||
|
expect(result.content).not.toContain("tool_call")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse boolean values", () => {
|
||||||
|
const response = `<tool_call name="git_diff">
|
||||||
|
<staged>true</staged>
|
||||||
|
</tool_call>`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls[0].params.staged).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse null values", () => {
|
||||||
|
const response = `<tool_call name="test">
|
||||||
|
<value>null</value>
|
||||||
|
</tool_call>`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls[0].params.value).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse JSON arrays", () => {
|
||||||
|
const response = `<tool_call name="git_commit">
|
||||||
|
<files>["a.ts", "b.ts"]</files>
|
||||||
|
</tool_call>`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls[0].params.files).toEqual(["a.ts", "b.ts"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse JSON objects", () => {
|
||||||
|
const response = `<tool_call name="test">
|
||||||
|
<config>{"key": "value"}</config>
|
||||||
|
</tool_call>`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls[0].params.config).toEqual({ key: "value" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for response without tool calls", () => {
|
||||||
|
const response = "This is just a regular response."
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(0)
|
||||||
|
expect(result.content).toBe(response)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle named param syntax", () => {
|
||||||
|
const response = `<tool_call name="get_lines">
|
||||||
|
<param name="path">src/index.ts</param>
|
||||||
|
<param name="start">5</param>
|
||||||
|
</tool_call>`
|
||||||
|
|
||||||
|
const result = parseToolCalls(response)
|
||||||
|
|
||||||
|
expect(result.toolCalls[0].params).toEqual({
|
||||||
|
path: "src/index.ts",
|
||||||
|
start: 5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("formatToolCallsAsXml", () => {
|
||||||
|
it("should format tool calls as XML", () => {
|
||||||
|
const toolCalls = [createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 })]
|
||||||
|
|
||||||
|
const xml = formatToolCallsAsXml(toolCalls)
|
||||||
|
|
||||||
|
expect(xml).toContain('<tool_call name="get_lines">')
|
||||||
|
expect(xml).toContain("<path>src/index.ts</path>")
|
||||||
|
expect(xml).toContain("<start>1</start>")
|
||||||
|
expect(xml).toContain("</tool_call>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should format multiple tool calls", () => {
|
||||||
|
const toolCalls = [
|
||||||
|
createToolCall("1", "get_lines", { path: "a.ts" }),
|
||||||
|
createToolCall("2", "get_function", { path: "b.ts", name: "foo" }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const xml = formatToolCallsAsXml(toolCalls)
|
||||||
|
|
||||||
|
expect(xml).toContain('<tool_call name="get_lines">')
|
||||||
|
expect(xml).toContain('<tool_call name="get_function">')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle object values as JSON", () => {
|
||||||
|
const toolCalls = [createToolCall("1", "test", { data: { key: "value" } })]
|
||||||
|
|
||||||
|
const xml = formatToolCallsAsXml(toolCalls)
|
||||||
|
|
||||||
|
expect(xml).toContain('{"key":"value"}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractThinking", () => {
|
||||||
|
it("should extract thinking content", () => {
|
||||||
|
const response = `<thinking>Let me analyze this.</thinking>
|
||||||
|
Here is the answer.`
|
||||||
|
|
||||||
|
const result = extractThinking(response)
|
||||||
|
|
||||||
|
expect(result.thinking).toBe("Let me analyze this.")
|
||||||
|
expect(result.content).toContain("Here is the answer.")
|
||||||
|
expect(result.content).not.toContain("thinking")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple thinking blocks", () => {
|
||||||
|
const response = `<thinking>First thought.</thinking>
|
||||||
|
Some content.
|
||||||
|
<thinking>Second thought.</thinking>
|
||||||
|
More content.`
|
||||||
|
|
||||||
|
const result = extractThinking(response)
|
||||||
|
|
||||||
|
expect(result.thinking).toContain("First thought.")
|
||||||
|
expect(result.thinking).toContain("Second thought.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return original content if no thinking", () => {
|
||||||
|
const response = "Just a regular response."
|
||||||
|
|
||||||
|
const result = extractThinking(response)
|
||||||
|
|
||||||
|
expect(result.thinking).toBe("")
|
||||||
|
expect(result.content).toBe(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasToolCalls", () => {
|
||||||
|
it("should return true if response has tool calls", () => {
|
||||||
|
const response = `<tool_call name="get_lines"><path>a.ts</path></tool_call>`
|
||||||
|
|
||||||
|
expect(hasToolCalls(response)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false if response has no tool calls", () => {
|
||||||
|
const response = "Just text without tool calls."
|
||||||
|
|
||||||
|
expect(hasToolCalls(response)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateToolCallParams", () => {
|
||||||
|
it("should return valid for complete params", () => {
|
||||||
|
const params = { path: "src/index.ts", start: 1, end: 10 }
|
||||||
|
const required = ["path", "start", "end"]
|
||||||
|
|
||||||
|
const result = validateToolCallParams("get_lines", params, required)
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return errors for missing required params", () => {
|
||||||
|
const params = { path: "src/index.ts" }
|
||||||
|
const required = ["path", "start", "end"]
|
||||||
|
|
||||||
|
const result = validateToolCallParams("get_lines", params, required)
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(2)
|
||||||
|
expect(result.errors).toContain("Missing required parameter: start")
|
||||||
|
expect(result.errors).toContain("Missing required parameter: end")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should treat null and undefined as missing", () => {
|
||||||
|
const params = { path: null, start: undefined }
|
||||||
|
const required = ["path", "start"]
|
||||||
|
|
||||||
|
const result = validateToolCallParams("test", params, required)
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept empty required array", () => {
|
||||||
|
const params = {}
|
||||||
|
const required: string[] = []
|
||||||
|
|
||||||
|
const result = validateToolCallParams("git_status", params, required)
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
717
packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts
Normal file
717
packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
SYSTEM_PROMPT,
|
||||||
|
buildInitialContext,
|
||||||
|
buildFileContext,
|
||||||
|
truncateContext,
|
||||||
|
type ProjectStructure,
|
||||||
|
} from "../../../../src/infrastructure/llm/prompts.js"
|
||||||
|
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
|
||||||
|
import type { FileMeta } from "../../../../src/domain/value-objects/FileMeta.js"
|
||||||
|
|
||||||
|
describe("prompts", () => {
|
||||||
|
describe("SYSTEM_PROMPT", () => {
|
||||||
|
it("should be a non-empty string", () => {
|
||||||
|
expect(typeof SYSTEM_PROMPT).toBe("string")
|
||||||
|
expect(SYSTEM_PROMPT.length).toBeGreaterThan(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should contain core principles", () => {
|
||||||
|
expect(SYSTEM_PROMPT).toContain("Lazy Loading")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("Precision")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("Safety")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should list available tools", () => {
|
||||||
|
expect(SYSTEM_PROMPT).toContain("get_lines")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("edit_lines")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("find_references")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("git_status")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("run_command")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include safety rules", () => {
|
||||||
|
expect(SYSTEM_PROMPT).toContain("Safety Rules")
|
||||||
|
expect(SYSTEM_PROMPT).toContain("Never execute commands that could harm")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildInitialContext", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "my-project",
|
||||||
|
rootPath: "/home/user/my-project",
|
||||||
|
files: ["src/index.ts", "src/utils.ts", "package.json"],
|
||||||
|
directories: ["src", "tests"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"src/index.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: "main",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 10,
|
||||||
|
params: [],
|
||||||
|
isAsync: false,
|
||||||
|
isExported: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/utils.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
name: "Helper",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 20,
|
||||||
|
methods: [],
|
||||||
|
properties: [],
|
||||||
|
implements: [],
|
||||||
|
isExported: true,
|
||||||
|
isAbstract: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
it("should include project header", () => {
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("# Project: my-project")
|
||||||
|
expect(context).toContain("Root: /home/user/my-project")
|
||||||
|
expect(context).toContain("Files: 3")
|
||||||
|
expect(context).toContain("Directories: 2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include directory structure", () => {
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("## Structure")
|
||||||
|
expect(context).toContain("src/")
|
||||||
|
expect(context).toContain("tests/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include file overview with AST summaries", () => {
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("## Files")
|
||||||
|
expect(context).toContain("src/index.ts")
|
||||||
|
expect(context).toContain("fn: main")
|
||||||
|
expect(context).toContain("src/utils.ts")
|
||||||
|
expect(context).toContain("class: Helper")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include file flags from metadata", () => {
|
||||||
|
const metas = new Map<string, FileMeta>([
|
||||||
|
[
|
||||||
|
"src/index.ts",
|
||||||
|
{
|
||||||
|
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 75 },
|
||||||
|
dependencies: [],
|
||||||
|
dependents: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"],
|
||||||
|
isHub: true,
|
||||||
|
isEntryPoint: true,
|
||||||
|
fileType: "source",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts, metas)
|
||||||
|
|
||||||
|
expect(context).toContain("(hub, entry, complex)")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildFileContext", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [
|
||||||
|
{ name: "fs", from: "node:fs", line: 1, type: "builtin", isDefault: false },
|
||||||
|
{ name: "helper", from: "./helper", line: 2, type: "internal", isDefault: true },
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
{ name: "main", line: 10, isDefault: false, kind: "function" },
|
||||||
|
{ name: "Config", line: 20, isDefault: true, kind: "class" },
|
||||||
|
],
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: "main",
|
||||||
|
lineStart: 10,
|
||||||
|
lineEnd: 30,
|
||||||
|
params: [
|
||||||
|
{ name: "args", optional: false, hasDefault: false },
|
||||||
|
{ name: "options", optional: true, hasDefault: false },
|
||||||
|
],
|
||||||
|
isAsync: true,
|
||||||
|
isExported: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
name: "Config",
|
||||||
|
lineStart: 40,
|
||||||
|
lineEnd: 80,
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
name: "load",
|
||||||
|
lineStart: 50,
|
||||||
|
lineEnd: 60,
|
||||||
|
params: [],
|
||||||
|
isAsync: false,
|
||||||
|
visibility: "public",
|
||||||
|
isStatic: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
extends: "BaseConfig",
|
||||||
|
implements: ["IConfig"],
|
||||||
|
isExported: true,
|
||||||
|
isAbstract: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should include file path header", () => {
|
||||||
|
const context = buildFileContext("src/index.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("## src/index.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include imports section", () => {
|
||||||
|
const context = buildFileContext("src/index.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("### Imports")
|
||||||
|
expect(context).toContain('fs from "node:fs" (builtin)')
|
||||||
|
expect(context).toContain('helper from "./helper" (internal)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include exports section", () => {
|
||||||
|
const context = buildFileContext("src/index.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("### Exports")
|
||||||
|
expect(context).toContain("function main")
|
||||||
|
expect(context).toContain("class Config (default)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include functions section", () => {
|
||||||
|
const context = buildFileContext("src/index.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("### Functions")
|
||||||
|
expect(context).toContain("async main(args, options)")
|
||||||
|
expect(context).toContain("[10-30]")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include classes section with methods", () => {
|
||||||
|
const context = buildFileContext("src/index.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("### Classes")
|
||||||
|
expect(context).toContain("Config extends BaseConfig implements IConfig")
|
||||||
|
expect(context).toContain("[40-80]")
|
||||||
|
expect(context).toContain("load()")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include metadata section when provided", () => {
|
||||||
|
const meta: FileMeta = {
|
||||||
|
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 65 },
|
||||||
|
dependencies: ["a.ts", "b.ts"],
|
||||||
|
dependents: ["c.ts"],
|
||||||
|
isHub: false,
|
||||||
|
isEntryPoint: true,
|
||||||
|
fileType: "source",
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("src/index.ts", ast, meta)
|
||||||
|
|
||||||
|
expect(context).toContain("### Metadata")
|
||||||
|
expect(context).toContain("LOC: 100")
|
||||||
|
expect(context).toContain("Complexity: 65/100")
|
||||||
|
expect(context).toContain("Dependencies: 2")
|
||||||
|
expect(context).toContain("Dependents: 1")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildFileContext - edge cases", () => {
|
||||||
|
it("should handle empty imports", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("empty.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("## empty.ts")
|
||||||
|
expect(context).not.toContain("### Imports")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty exports", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [{ name: "x", from: "./x", line: 1, type: "internal", isDefault: false }],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("no-exports.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("### Imports")
|
||||||
|
expect(context).not.toContain("### Exports")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty functions", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
name: "MyClass",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 10,
|
||||||
|
methods: [],
|
||||||
|
properties: [],
|
||||||
|
implements: [],
|
||||||
|
isExported: false,
|
||||||
|
isAbstract: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("no-functions.ts", ast)
|
||||||
|
|
||||||
|
expect(context).not.toContain("### Functions")
|
||||||
|
expect(context).toContain("### Classes")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty classes", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: "test",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 5,
|
||||||
|
params: [],
|
||||||
|
isAsync: false,
|
||||||
|
isExported: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("no-classes.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("### Functions")
|
||||||
|
expect(context).not.toContain("### Classes")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle class without extends", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
name: "Standalone",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 10,
|
||||||
|
methods: [],
|
||||||
|
properties: [],
|
||||||
|
implements: ["IFoo"],
|
||||||
|
isExported: false,
|
||||||
|
isAbstract: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("standalone.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("Standalone implements IFoo")
|
||||||
|
expect(context).not.toContain("extends")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle class without implements", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
name: "Child",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 10,
|
||||||
|
methods: [],
|
||||||
|
properties: [],
|
||||||
|
extends: "Parent",
|
||||||
|
implements: [],
|
||||||
|
isExported: false,
|
||||||
|
isAbstract: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("child.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("Child extends Parent")
|
||||||
|
expect(context).not.toContain("implements")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle method with private visibility", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
name: "WithPrivate",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 20,
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
name: "secretMethod",
|
||||||
|
lineStart: 5,
|
||||||
|
lineEnd: 10,
|
||||||
|
params: [],
|
||||||
|
isAsync: false,
|
||||||
|
visibility: "private",
|
||||||
|
isStatic: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
implements: [],
|
||||||
|
isExported: false,
|
||||||
|
isAbstract: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("private.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("private secretMethod()")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle non-async function", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: "syncFn",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 5,
|
||||||
|
params: [{ name: "x", optional: false, hasDefault: false }],
|
||||||
|
isAsync: false,
|
||||||
|
isExported: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("sync.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("syncFn(x)")
|
||||||
|
expect(context).not.toContain("async syncFn")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle export without default", () => {
|
||||||
|
const ast: FileAST = {
|
||||||
|
imports: [],
|
||||||
|
exports: [{ name: "foo", line: 1, isDefault: false, kind: "variable" }],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildFileContext("named-export.ts", ast)
|
||||||
|
|
||||||
|
expect(context).toContain("variable foo")
|
||||||
|
expect(context).not.toContain("(default)")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildInitialContext - edge cases", () => {
|
||||||
|
it("should handle nested directory names", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: [],
|
||||||
|
directories: ["src/components/ui"],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>()
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("ui/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle file with only interfaces", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: ["types.ts"],
|
||||||
|
directories: [],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"types.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [{ name: "IFoo", lineStart: 1, lineEnd: 5, isExported: true }],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("interface: IFoo")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle file with only type aliases", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: ["types.ts"],
|
||||||
|
directories: [],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"types.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [
|
||||||
|
{ name: "MyType", lineStart: 1, lineEnd: 1, isExported: true },
|
||||||
|
],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("type: MyType")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle file with no AST content", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: ["empty.ts"],
|
||||||
|
directories: [],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"empty.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("- empty.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle meta with only hub flag", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: ["hub.ts"],
|
||||||
|
directories: [],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"hub.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
const metas = new Map<string, FileMeta>([
|
||||||
|
[
|
||||||
|
"hub.ts",
|
||||||
|
{
|
||||||
|
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
|
||||||
|
dependencies: [],
|
||||||
|
dependents: [],
|
||||||
|
isHub: true,
|
||||||
|
isEntryPoint: false,
|
||||||
|
fileType: "source",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts, metas)
|
||||||
|
|
||||||
|
expect(context).toContain("(hub)")
|
||||||
|
expect(context).not.toContain("entry")
|
||||||
|
expect(context).not.toContain("complex")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle meta with no flags", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: ["normal.ts"],
|
||||||
|
directories: [],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"normal.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
const metas = new Map<string, FileMeta>([
|
||||||
|
[
|
||||||
|
"normal.ts",
|
||||||
|
{
|
||||||
|
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
|
||||||
|
dependencies: [],
|
||||||
|
dependents: [],
|
||||||
|
isHub: false,
|
||||||
|
isEntryPoint: false,
|
||||||
|
fileType: "source",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts, metas)
|
||||||
|
|
||||||
|
expect(context).toContain("- normal.ts")
|
||||||
|
expect(context).not.toContain("(hub")
|
||||||
|
expect(context).not.toContain("entry")
|
||||||
|
expect(context).not.toContain("complex")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should skip files not in AST map", () => {
|
||||||
|
const structure: ProjectStructure = {
|
||||||
|
name: "test",
|
||||||
|
rootPath: "/test",
|
||||||
|
files: ["exists.ts", "missing.ts"],
|
||||||
|
directories: [],
|
||||||
|
}
|
||||||
|
const asts = new Map<string, FileAST>([
|
||||||
|
[
|
||||||
|
"exists.ts",
|
||||||
|
{
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const context = buildInitialContext(structure, asts)
|
||||||
|
|
||||||
|
expect(context).toContain("exists.ts")
|
||||||
|
expect(context).not.toContain("missing.ts")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("truncateContext", () => {
|
||||||
|
it("should return original context if within limit", () => {
|
||||||
|
const context = "Short context"
|
||||||
|
|
||||||
|
const result = truncateContext(context, 1000)
|
||||||
|
|
||||||
|
expect(result).toBe(context)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should truncate long context", () => {
|
||||||
|
const context = "a".repeat(1000)
|
||||||
|
|
||||||
|
const result = truncateContext(context, 100)
|
||||||
|
|
||||||
|
expect(result.length).toBeLessThan(500)
|
||||||
|
expect(result).toContain("truncated")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should break at newline boundary", () => {
|
||||||
|
const context = "Line 1\nLine 2\nLine 3\n" + "a".repeat(1000)
|
||||||
|
|
||||||
|
const result = truncateContext(context, 50)
|
||||||
|
|
||||||
|
expect(result).toContain("truncated")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
287
packages/ipuaro/tests/unit/infrastructure/llm/toolDefs.test.ts
Normal file
287
packages/ipuaro/tests/unit/infrastructure/llm/toolDefs.test.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
ALL_TOOLS,
|
||||||
|
READ_TOOLS,
|
||||||
|
EDIT_TOOLS,
|
||||||
|
SEARCH_TOOLS,
|
||||||
|
ANALYSIS_TOOLS,
|
||||||
|
GIT_TOOLS,
|
||||||
|
RUN_TOOLS,
|
||||||
|
CONFIRMATION_TOOLS,
|
||||||
|
requiresConfirmation,
|
||||||
|
getToolDef,
|
||||||
|
getToolsByCategory,
|
||||||
|
GET_LINES_TOOL,
|
||||||
|
GET_FUNCTION_TOOL,
|
||||||
|
GET_CLASS_TOOL,
|
||||||
|
GET_STRUCTURE_TOOL,
|
||||||
|
EDIT_LINES_TOOL,
|
||||||
|
CREATE_FILE_TOOL,
|
||||||
|
DELETE_FILE_TOOL,
|
||||||
|
FIND_REFERENCES_TOOL,
|
||||||
|
FIND_DEFINITION_TOOL,
|
||||||
|
GET_DEPENDENCIES_TOOL,
|
||||||
|
GET_DEPENDENTS_TOOL,
|
||||||
|
GET_COMPLEXITY_TOOL,
|
||||||
|
GET_TODOS_TOOL,
|
||||||
|
GIT_STATUS_TOOL,
|
||||||
|
GIT_DIFF_TOOL,
|
||||||
|
GIT_COMMIT_TOOL,
|
||||||
|
RUN_COMMAND_TOOL,
|
||||||
|
RUN_TESTS_TOOL,
|
||||||
|
} from "../../../../src/infrastructure/llm/toolDefs.js"
|
||||||
|
|
||||||
|
describe("toolDefs", () => {
|
||||||
|
describe("ALL_TOOLS", () => {
|
||||||
|
it("should contain exactly 18 tools", () => {
|
||||||
|
expect(ALL_TOOLS).toHaveLength(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have unique tool names", () => {
|
||||||
|
const names = ALL_TOOLS.map((t) => t.name)
|
||||||
|
const uniqueNames = new Set(names)
|
||||||
|
expect(uniqueNames.size).toBe(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have valid structure for all tools", () => {
|
||||||
|
for (const tool of ALL_TOOLS) {
|
||||||
|
expect(tool.name).toBeDefined()
|
||||||
|
expect(typeof tool.name).toBe("string")
|
||||||
|
expect(tool.description).toBeDefined()
|
||||||
|
expect(typeof tool.description).toBe("string")
|
||||||
|
expect(Array.isArray(tool.parameters)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("READ_TOOLS", () => {
|
||||||
|
it("should contain 4 read tools", () => {
|
||||||
|
expect(READ_TOOLS).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include all read tools", () => {
|
||||||
|
expect(READ_TOOLS).toContain(GET_LINES_TOOL)
|
||||||
|
expect(READ_TOOLS).toContain(GET_FUNCTION_TOOL)
|
||||||
|
expect(READ_TOOLS).toContain(GET_CLASS_TOOL)
|
||||||
|
expect(READ_TOOLS).toContain(GET_STRUCTURE_TOOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("EDIT_TOOLS", () => {
|
||||||
|
it("should contain 3 edit tools", () => {
|
||||||
|
expect(EDIT_TOOLS).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include all edit tools", () => {
|
||||||
|
expect(EDIT_TOOLS).toContain(EDIT_LINES_TOOL)
|
||||||
|
expect(EDIT_TOOLS).toContain(CREATE_FILE_TOOL)
|
||||||
|
expect(EDIT_TOOLS).toContain(DELETE_FILE_TOOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SEARCH_TOOLS", () => {
|
||||||
|
it("should contain 2 search tools", () => {
|
||||||
|
expect(SEARCH_TOOLS).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include all search tools", () => {
|
||||||
|
expect(SEARCH_TOOLS).toContain(FIND_REFERENCES_TOOL)
|
||||||
|
expect(SEARCH_TOOLS).toContain(FIND_DEFINITION_TOOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("ANALYSIS_TOOLS", () => {
|
||||||
|
it("should contain 4 analysis tools", () => {
|
||||||
|
expect(ANALYSIS_TOOLS).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include all analysis tools", () => {
|
||||||
|
expect(ANALYSIS_TOOLS).toContain(GET_DEPENDENCIES_TOOL)
|
||||||
|
expect(ANALYSIS_TOOLS).toContain(GET_DEPENDENTS_TOOL)
|
||||||
|
expect(ANALYSIS_TOOLS).toContain(GET_COMPLEXITY_TOOL)
|
||||||
|
expect(ANALYSIS_TOOLS).toContain(GET_TODOS_TOOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GIT_TOOLS", () => {
|
||||||
|
it("should contain 3 git tools", () => {
|
||||||
|
expect(GIT_TOOLS).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include all git tools", () => {
|
||||||
|
expect(GIT_TOOLS).toContain(GIT_STATUS_TOOL)
|
||||||
|
expect(GIT_TOOLS).toContain(GIT_DIFF_TOOL)
|
||||||
|
expect(GIT_TOOLS).toContain(GIT_COMMIT_TOOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("RUN_TOOLS", () => {
|
||||||
|
it("should contain 2 run tools", () => {
|
||||||
|
expect(RUN_TOOLS).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include all run tools", () => {
|
||||||
|
expect(RUN_TOOLS).toContain(RUN_COMMAND_TOOL)
|
||||||
|
expect(RUN_TOOLS).toContain(RUN_TESTS_TOOL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("individual tool definitions", () => {
|
||||||
|
describe("GET_LINES_TOOL", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(GET_LINES_TOOL.name).toBe("get_lines")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have required path parameter", () => {
|
||||||
|
const pathParam = GET_LINES_TOOL.parameters.find((p) => p.name === "path")
|
||||||
|
expect(pathParam).toBeDefined()
|
||||||
|
expect(pathParam?.required).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have optional start and end parameters", () => {
|
||||||
|
const startParam = GET_LINES_TOOL.parameters.find((p) => p.name === "start")
|
||||||
|
const endParam = GET_LINES_TOOL.parameters.find((p) => p.name === "end")
|
||||||
|
expect(startParam?.required).toBe(false)
|
||||||
|
expect(endParam?.required).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("EDIT_LINES_TOOL", () => {
|
||||||
|
it("should have all required parameters", () => {
|
||||||
|
const requiredParams = EDIT_LINES_TOOL.parameters.filter((p) => p.required)
|
||||||
|
const names = requiredParams.map((p) => p.name)
|
||||||
|
expect(names).toContain("path")
|
||||||
|
expect(names).toContain("start")
|
||||||
|
expect(names).toContain("end")
|
||||||
|
expect(names).toContain("content")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GIT_STATUS_TOOL", () => {
|
||||||
|
it("should have no required parameters", () => {
|
||||||
|
expect(GIT_STATUS_TOOL.parameters).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GET_TODOS_TOOL", () => {
|
||||||
|
it("should have enum for type parameter", () => {
|
||||||
|
const typeParam = GET_TODOS_TOOL.parameters.find((p) => p.name === "type")
|
||||||
|
expect(typeParam?.enum).toEqual(["TODO", "FIXME", "HACK", "XXX"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("CONFIRMATION_TOOLS", () => {
|
||||||
|
it("should be a Set", () => {
|
||||||
|
expect(CONFIRMATION_TOOLS instanceof Set).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should contain edit and git_commit tools", () => {
|
||||||
|
expect(CONFIRMATION_TOOLS.has("edit_lines")).toBe(true)
|
||||||
|
expect(CONFIRMATION_TOOLS.has("create_file")).toBe(true)
|
||||||
|
expect(CONFIRMATION_TOOLS.has("delete_file")).toBe(true)
|
||||||
|
expect(CONFIRMATION_TOOLS.has("git_commit")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not contain read tools", () => {
|
||||||
|
expect(CONFIRMATION_TOOLS.has("get_lines")).toBe(false)
|
||||||
|
expect(CONFIRMATION_TOOLS.has("get_function")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("requiresConfirmation", () => {
|
||||||
|
it("should return true for edit tools", () => {
|
||||||
|
expect(requiresConfirmation("edit_lines")).toBe(true)
|
||||||
|
expect(requiresConfirmation("create_file")).toBe(true)
|
||||||
|
expect(requiresConfirmation("delete_file")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true for git_commit", () => {
|
||||||
|
expect(requiresConfirmation("git_commit")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for read tools", () => {
|
||||||
|
expect(requiresConfirmation("get_lines")).toBe(false)
|
||||||
|
expect(requiresConfirmation("get_function")).toBe(false)
|
||||||
|
expect(requiresConfirmation("get_structure")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for analysis tools", () => {
|
||||||
|
expect(requiresConfirmation("get_dependencies")).toBe(false)
|
||||||
|
expect(requiresConfirmation("get_complexity")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for unknown tools", () => {
|
||||||
|
expect(requiresConfirmation("unknown_tool")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getToolDef", () => {
|
||||||
|
it("should return tool definition by name", () => {
|
||||||
|
const tool = getToolDef("get_lines")
|
||||||
|
expect(tool).toBe(GET_LINES_TOOL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined for unknown tool", () => {
|
||||||
|
const tool = getToolDef("unknown_tool")
|
||||||
|
expect(tool).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should find all 18 tools", () => {
|
||||||
|
const names = [
|
||||||
|
"get_lines",
|
||||||
|
"get_function",
|
||||||
|
"get_class",
|
||||||
|
"get_structure",
|
||||||
|
"edit_lines",
|
||||||
|
"create_file",
|
||||||
|
"delete_file",
|
||||||
|
"find_references",
|
||||||
|
"find_definition",
|
||||||
|
"get_dependencies",
|
||||||
|
"get_dependents",
|
||||||
|
"get_complexity",
|
||||||
|
"get_todos",
|
||||||
|
"git_status",
|
||||||
|
"git_diff",
|
||||||
|
"git_commit",
|
||||||
|
"run_command",
|
||||||
|
"run_tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
expect(getToolDef(name)).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getToolsByCategory", () => {
|
||||||
|
it("should return read tools", () => {
|
||||||
|
expect(getToolsByCategory("read")).toBe(READ_TOOLS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return edit tools", () => {
|
||||||
|
expect(getToolsByCategory("edit")).toBe(EDIT_TOOLS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return search tools", () => {
|
||||||
|
expect(getToolsByCategory("search")).toBe(SEARCH_TOOLS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return analysis tools", () => {
|
||||||
|
expect(getToolsByCategory("analysis")).toBe(ANALYSIS_TOOLS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return git tools", () => {
|
||||||
|
expect(getToolsByCategory("git")).toBe(GIT_TOOLS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return run tools", () => {
|
||||||
|
expect(getToolsByCategory("run")).toBe(RUN_TOOLS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for unknown category", () => {
|
||||||
|
expect(getToolsByCategory("unknown")).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import * as os from "node:os"
|
||||||
|
import {
|
||||||
|
CreateFileTool,
|
||||||
|
type CreateFileResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/edit/CreateFileTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
import { hashLines } from "../../../../../src/shared/utils/hash.js"
|
||||||
|
|
||||||
|
function createMockStorage(): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn().mockResolvedValue(null),
|
||||||
|
setFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getFileCount: vi.fn(),
|
||||||
|
getAST: vi.fn(),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
deleteAST: vi.fn(),
|
||||||
|
getAllASTs: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
deleteMeta: vi.fn(),
|
||||||
|
getAllMetas: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getProjectConfig: vi.fn(),
|
||||||
|
setProjectConfig: vi.fn(),
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
isConnected: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
} as unknown as IStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContext(
|
||||||
|
storage?: IStorage,
|
||||||
|
confirmResult = true,
|
||||||
|
projectRoot = "/test/project",
|
||||||
|
): ToolContext {
|
||||||
|
return {
|
||||||
|
projectRoot,
|
||||||
|
storage: storage ?? createMockStorage(),
|
||||||
|
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CreateFileTool", () => {
|
||||||
|
let tool: CreateFileTool
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new CreateFileTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("create_file")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("edit")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should require confirmation", () => {
|
||||||
|
expect(tool.requiresConfirmation).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct parameters", () => {
|
||||||
|
expect(tool.parameters).toHaveLength(2)
|
||||||
|
expect(tool.parameters[0].name).toBe("path")
|
||||||
|
expect(tool.parameters[0].required).toBe(true)
|
||||||
|
expect(tool.parameters[1].name).toBe("content")
|
||||||
|
expect(tool.parameters[1].required).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have description mentioning confirmation", () => {
|
||||||
|
expect(tool.description).toContain("confirmation")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for valid params", () => {
|
||||||
|
expect(
|
||||||
|
tool.validateParams({ path: "src/new-file.ts", content: "const x = 1" }),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing path", () => {
|
||||||
|
expect(tool.validateParams({ content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty path", () => {
|
||||||
|
expect(tool.validateParams({ path: "", content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: " ", content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-string path", () => {
|
||||||
|
expect(tool.validateParams({ path: 123, content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing content", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts" })).toBe(
|
||||||
|
"Parameter 'content' is required and must be a string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-string content", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", content: 123 })).toBe(
|
||||||
|
"Parameter 'content' is required and must be a string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow empty content string", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", content: "" })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
let tempDir: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "create-file-test-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create new file with content", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const content = "line 1\nline 2\nline 3"
|
||||||
|
const result = await tool.execute({ path: "new-file.ts", content }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as CreateFileResult
|
||||||
|
expect(data.path).toBe("new-file.ts")
|
||||||
|
expect(data.lines).toBe(3)
|
||||||
|
|
||||||
|
const filePath = path.join(tempDir, "new-file.ts")
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
expect(fileContent).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create directories if they do not exist", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "deep/nested/dir/file.ts", content: "test" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
const filePath = path.join(tempDir, "deep/nested/dir/file.ts")
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
expect(fileContent).toBe("test")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should call requestConfirmation with diff info", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
|
||||||
|
|
||||||
|
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
|
||||||
|
"Create new file: new-file.ts (2 lines)",
|
||||||
|
{
|
||||||
|
filePath: "new-file.ts",
|
||||||
|
oldLines: [],
|
||||||
|
newLines: ["line 1", "line 2"],
|
||||||
|
startLine: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should cancel creation when confirmation rejected", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, false, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "new-file.ts", content: "test" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("File creation cancelled by user")
|
||||||
|
|
||||||
|
const filePath = path.join(tempDir, "new-file.ts")
|
||||||
|
await expect(fs.access(filePath)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update storage after creation", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
|
||||||
|
|
||||||
|
expect(storage.setFile).toHaveBeenCalledWith(
|
||||||
|
"new-file.ts",
|
||||||
|
expect.objectContaining({
|
||||||
|
lines: ["line 1", "line 2"],
|
||||||
|
hash: hashLines(["line 1", "line 2"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext(undefined, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error if file already exists", async () => {
|
||||||
|
const existingFile = path.join(tempDir, "existing.ts")
|
||||||
|
await fs.writeFile(existingFile, "original content", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "existing.ts", content: "new content" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("File already exists: existing.ts")
|
||||||
|
|
||||||
|
const content = await fs.readFile(existingFile, "utf-8")
|
||||||
|
expect(content).toBe("original content")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty content", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "empty.ts", content: "" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as CreateFileResult
|
||||||
|
expect(data.lines).toBe(1)
|
||||||
|
|
||||||
|
const filePath = path.join(tempDir, "empty.ts")
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
expect(fileContent).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle single line content", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "single.ts", content: "export const x = 1" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as CreateFileResult
|
||||||
|
expect(data.lines).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return correct file size", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const content = "hello world"
|
||||||
|
const result = await tool.execute({ path: "file.ts", content }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as CreateFileResult
|
||||||
|
expect(data.size).toBe(Buffer.byteLength(content, "utf-8"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^create_file-\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include executionTimeMs in result", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
|
||||||
|
|
||||||
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multi-line content correctly", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const content = "import { x } from './x'\n\nexport function foo() {\n return x\n}\n"
|
||||||
|
const result = await tool.execute({ path: "foo.ts", content }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as CreateFileResult
|
||||||
|
expect(data.lines).toBe(6)
|
||||||
|
|
||||||
|
const filePath = path.join(tempDir, "foo.ts")
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
expect(fileContent).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle special characters in content", async () => {
|
||||||
|
const storage = createMockStorage()
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const content = "const emoji = '🚀'\nconst quote = \"hello 'world'\""
|
||||||
|
const result = await tool.execute({ path: "special.ts", content }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
const filePath = path.join(tempDir, "special.ts")
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
expect(fileContent).toBe(content)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import * as os from "node:os"
|
||||||
|
import {
|
||||||
|
DeleteFileTool,
|
||||||
|
type DeleteFileResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/edit/DeleteFileTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
|
||||||
|
function createMockStorage(fileData: { lines: string[] } | null = null): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn().mockResolvedValue(fileData),
|
||||||
|
setFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getFileCount: vi.fn(),
|
||||||
|
getAST: vi.fn(),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
deleteAST: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAllASTs: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
deleteMeta: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAllMetas: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getProjectConfig: vi.fn(),
|
||||||
|
setProjectConfig: vi.fn(),
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
isConnected: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
} as unknown as IStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContext(
|
||||||
|
storage?: IStorage,
|
||||||
|
confirmResult = true,
|
||||||
|
projectRoot = "/test/project",
|
||||||
|
): ToolContext {
|
||||||
|
return {
|
||||||
|
projectRoot,
|
||||||
|
storage: storage ?? createMockStorage(),
|
||||||
|
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DeleteFileTool", () => {
|
||||||
|
let tool: DeleteFileTool
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new DeleteFileTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("delete_file")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("edit")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should require confirmation", () => {
|
||||||
|
expect(tool.requiresConfirmation).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct parameters", () => {
|
||||||
|
expect(tool.parameters).toHaveLength(1)
|
||||||
|
expect(tool.parameters[0].name).toBe("path")
|
||||||
|
expect(tool.parameters[0].required).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have description mentioning confirmation", () => {
|
||||||
|
expect(tool.description).toContain("confirmation")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for valid params", () => {
|
||||||
|
expect(tool.validateParams({ path: "src/file.ts" })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing path", () => {
|
||||||
|
expect(tool.validateParams({})).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty path", () => {
|
||||||
|
expect(tool.validateParams({ path: "" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: " " })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-string path", () => {
|
||||||
|
expect(tool.validateParams({ path: 123 })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
let tempDir: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delete-file-test-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should delete existing file", async () => {
|
||||||
|
const testFile = path.join(tempDir, "to-delete.ts")
|
||||||
|
await fs.writeFile(testFile, "content to delete", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["content to delete"] })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as DeleteFileResult
|
||||||
|
expect(data.path).toBe("to-delete.ts")
|
||||||
|
expect(data.deleted).toBe(true)
|
||||||
|
|
||||||
|
await expect(fs.access(testFile)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should delete file from storage", async () => {
|
||||||
|
const testFile = path.join(tempDir, "to-delete.ts")
|
||||||
|
await fs.writeFile(testFile, "content", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["content"] })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(storage.deleteFile).toHaveBeenCalledWith("to-delete.ts")
|
||||||
|
expect(storage.deleteAST).toHaveBeenCalledWith("to-delete.ts")
|
||||||
|
expect(storage.deleteMeta).toHaveBeenCalledWith("to-delete.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should call requestConfirmation with diff info", async () => {
|
||||||
|
const testFile = path.join(tempDir, "to-delete.ts")
|
||||||
|
await fs.writeFile(testFile, "line 1\nline 2", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["line 1", "line 2"] })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Delete file: to-delete.ts", {
|
||||||
|
filePath: "to-delete.ts",
|
||||||
|
oldLines: ["line 1", "line 2"],
|
||||||
|
newLines: [],
|
||||||
|
startLine: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should cancel deletion when confirmation rejected", async () => {
|
||||||
|
const testFile = path.join(tempDir, "keep.ts")
|
||||||
|
await fs.writeFile(testFile, "keep this", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["keep this"] })
|
||||||
|
const ctx = createMockContext(storage, false, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "keep.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("File deletion cancelled by user")
|
||||||
|
|
||||||
|
const content = await fs.readFile(testFile, "utf-8")
|
||||||
|
expect(content).toBe("keep this")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext(undefined, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error if file does not exist", async () => {
|
||||||
|
const storage = createMockStorage(null)
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("File not found: nonexistent.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should read content from filesystem if not in storage", async () => {
|
||||||
|
const testFile = path.join(tempDir, "not-indexed.ts")
|
||||||
|
await fs.writeFile(testFile, "filesystem content\nline 2", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage(null)
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "not-indexed.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
|
||||||
|
"Delete file: not-indexed.ts",
|
||||||
|
expect.objectContaining({
|
||||||
|
oldLines: ["filesystem content", "line 2"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const testFile = path.join(tempDir, "file.ts")
|
||||||
|
await fs.writeFile(testFile, "x", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["x"] })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^delete_file-\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include executionTimeMs in result", async () => {
|
||||||
|
const testFile = path.join(tempDir, "file.ts")
|
||||||
|
await fs.writeFile(testFile, "x", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["x"] })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not delete directories", async () => {
|
||||||
|
const dirPath = path.join(tempDir, "some-dir")
|
||||||
|
await fs.mkdir(dirPath)
|
||||||
|
|
||||||
|
const storage = createMockStorage(null)
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "some-dir" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("File not found: some-dir")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle nested file paths", async () => {
|
||||||
|
const nestedDir = path.join(tempDir, "a/b/c")
|
||||||
|
await fs.mkdir(nestedDir, { recursive: true })
|
||||||
|
const testFile = path.join(nestedDir, "file.ts")
|
||||||
|
await fs.writeFile(testFile, "nested", "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage({ lines: ["nested"] })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "a/b/c/file.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
await expect(fs.access(testFile)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import * as os from "node:os"
|
||||||
|
import {
|
||||||
|
EditLinesTool,
|
||||||
|
type EditLinesResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/edit/EditLinesTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
import { hashLines } from "../../../../../src/shared/utils/hash.js"
|
||||||
|
|
||||||
|
function createMockStorage(fileData: { lines: string[]; hash: string } | null = null): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn().mockResolvedValue(fileData),
|
||||||
|
setFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getFileCount: vi.fn(),
|
||||||
|
getAST: vi.fn(),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
deleteAST: vi.fn(),
|
||||||
|
getAllASTs: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
deleteMeta: vi.fn(),
|
||||||
|
getAllMetas: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getProjectConfig: vi.fn(),
|
||||||
|
setProjectConfig: vi.fn(),
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
isConnected: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
} as unknown as IStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContext(
|
||||||
|
storage?: IStorage,
|
||||||
|
confirmResult = true,
|
||||||
|
projectRoot = "/test/project",
|
||||||
|
): ToolContext {
|
||||||
|
return {
|
||||||
|
projectRoot,
|
||||||
|
storage: storage ?? createMockStorage(),
|
||||||
|
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EditLinesTool", () => {
|
||||||
|
let tool: EditLinesTool
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new EditLinesTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("edit_lines")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("edit")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should require confirmation", () => {
|
||||||
|
expect(tool.requiresConfirmation).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct parameters", () => {
|
||||||
|
expect(tool.parameters).toHaveLength(4)
|
||||||
|
expect(tool.parameters[0].name).toBe("path")
|
||||||
|
expect(tool.parameters[0].required).toBe(true)
|
||||||
|
expect(tool.parameters[1].name).toBe("start")
|
||||||
|
expect(tool.parameters[1].required).toBe(true)
|
||||||
|
expect(tool.parameters[2].name).toBe("end")
|
||||||
|
expect(tool.parameters[2].required).toBe(true)
|
||||||
|
expect(tool.parameters[3].name).toBe("content")
|
||||||
|
expect(tool.parameters[3].required).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have description mentioning confirmation", () => {
|
||||||
|
expect(tool.description).toContain("confirmation")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for valid params", () => {
|
||||||
|
expect(
|
||||||
|
tool.validateParams({
|
||||||
|
path: "src/index.ts",
|
||||||
|
start: 1,
|
||||||
|
end: 5,
|
||||||
|
content: "new content",
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing path", () => {
|
||||||
|
expect(tool.validateParams({ start: 1, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty path", () => {
|
||||||
|
expect(tool.validateParams({ path: "", start: 1, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: " ", start: 1, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-string path", () => {
|
||||||
|
expect(tool.validateParams({ path: 123, start: 1, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing start", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'start' is required and must be an integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-integer start", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1.5, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'start' is required and must be an integer",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: "1", end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'start' is required and must be an integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for start < 1", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 0, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'start' must be >= 1",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: -1, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'start' must be >= 1",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing end", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1, content: "x" })).toBe(
|
||||||
|
"Parameter 'end' is required and must be an integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-integer end", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5.5, content: "x" })).toBe(
|
||||||
|
"Parameter 'end' is required and must be an integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for end < 1", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1, end: 0, content: "x" })).toBe(
|
||||||
|
"Parameter 'end' must be >= 1",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for start > end", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 10, end: 5, content: "x" })).toBe(
|
||||||
|
"Parameter 'start' must be <= 'end'",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing content", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5 })).toBe(
|
||||||
|
"Parameter 'content' is required and must be a string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-string content", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5, content: 123 })).toBe(
|
||||||
|
"Parameter 'content' is required and must be a string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow empty content string", () => {
|
||||||
|
expect(
|
||||||
|
tool.validateParams({ path: "test.ts", start: 1, end: 5, content: "" }),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
let tempDir: string
|
||||||
|
let testFilePath: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-lines-test-"))
|
||||||
|
testFilePath = path.join(tempDir, "test.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should replace lines with new content", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
|
||||||
|
const originalContent = originalLines.join("\n")
|
||||||
|
await fs.writeFile(testFilePath, originalContent, "utf-8")
|
||||||
|
|
||||||
|
const lines = [...originalLines]
|
||||||
|
const hash = hashLines(lines)
|
||||||
|
const storage = createMockStorage({ lines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 2, end: 4, content: "new line A\nnew line B" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as EditLinesResult
|
||||||
|
expect(data.path).toBe("test.ts")
|
||||||
|
expect(data.startLine).toBe(2)
|
||||||
|
expect(data.endLine).toBe(4)
|
||||||
|
expect(data.linesReplaced).toBe(3)
|
||||||
|
expect(data.linesInserted).toBe(2)
|
||||||
|
expect(data.totalLines).toBe(4)
|
||||||
|
|
||||||
|
const newContent = await fs.readFile(testFilePath, "utf-8")
|
||||||
|
expect(newContent).toBe("line 1\nnew line A\nnew line B\nline 5")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should call requestConfirmation with diff info", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2", "line 3"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "test.ts", start: 2, end: 2, content: "replaced" }, ctx)
|
||||||
|
|
||||||
|
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Replace lines 2-2 in test.ts", {
|
||||||
|
filePath: "test.ts",
|
||||||
|
oldLines: ["line 2"],
|
||||||
|
newLines: ["replaced"],
|
||||||
|
startLine: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should cancel edit when confirmation rejected", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2", "line 3"]
|
||||||
|
const originalContent = originalLines.join("\n")
|
||||||
|
await fs.writeFile(testFilePath, originalContent, "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, false, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 1, content: "changed" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Edit cancelled by user")
|
||||||
|
|
||||||
|
const content = await fs.readFile(testFilePath, "utf-8")
|
||||||
|
expect(content).toBe(originalContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update storage after edit", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
await tool.execute({ path: "test.ts", start: 1, end: 1, content: "changed" }, ctx)
|
||||||
|
|
||||||
|
expect(storage.setFile).toHaveBeenCalledWith(
|
||||||
|
"test.ts",
|
||||||
|
expect.objectContaining({
|
||||||
|
lines: ["changed", "line 2"],
|
||||||
|
hash: hashLines(["changed", "line 2"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "../outside/file.ts", start: 1, end: 1, content: "x" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when start exceeds file length", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 10, end: 15, content: "x" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Start line 10 exceeds file length (2 lines)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should adjust end to file length if it exceeds", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2", "line 3"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 2, end: 100, content: "new" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as EditLinesResult
|
||||||
|
expect(data.endLine).toBe(3)
|
||||||
|
expect(data.linesReplaced).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect hash conflict", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const oldHash = hashLines(["old content"])
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash: oldHash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe(
|
||||||
|
"File has been modified externally. Please refresh the file before editing.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow edit when file not in storage", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const storage = createMockStorage(null)
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle single line replacement", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2", "line 3"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 2, end: 2, content: "replaced line 2" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const content = await fs.readFile(testFilePath, "utf-8")
|
||||||
|
expect(content).toBe("line 1\nreplaced line 2\nline 3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle replacing all lines", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 2, content: "completely\nnew\nfile" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const content = await fs.readFile(testFilePath, "utf-8")
|
||||||
|
expect(content).toBe("completely\nnew\nfile")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle inserting more lines than replaced", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 1, content: "a\nb\nc\nd" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as EditLinesResult
|
||||||
|
expect(data.linesReplaced).toBe(1)
|
||||||
|
expect(data.linesInserted).toBe(4)
|
||||||
|
expect(data.totalLines).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle deleting lines (empty content)", async () => {
|
||||||
|
const originalLines = ["line 1", "line 2", "line 3"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 2, end: 2, content: "" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as EditLinesResult
|
||||||
|
expect(data.linesReplaced).toBe(1)
|
||||||
|
expect(data.linesInserted).toBe(1)
|
||||||
|
expect(data.totalLines).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const originalLines = ["line 1"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^edit_lines-\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include executionTimeMs in result", async () => {
|
||||||
|
const originalLines = ["line 1"]
|
||||||
|
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||||
|
|
||||||
|
const hash = hashLines(originalLines)
|
||||||
|
const storage = createMockStorage({ lines: originalLines, hash })
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when file not found", async () => {
|
||||||
|
const storage = createMockStorage(null)
|
||||||
|
const ctx = createMockContext(storage, true, tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: "nonexistent.ts", start: 1, end: 1, content: "x" },
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("ENOENT")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import {
|
||||||
|
GetClassTool,
|
||||||
|
type GetClassResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/read/GetClassTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
import type { FileAST, ClassInfo } from "../../../../../src/domain/value-objects/FileAST.js"
|
||||||
|
|
||||||
|
function createMockClass(overrides: Partial<ClassInfo> = {}): ClassInfo {
|
||||||
|
return {
|
||||||
|
name: "TestClass",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 10,
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
name: "testMethod",
|
||||||
|
lineStart: 3,
|
||||||
|
lineEnd: 5,
|
||||||
|
params: [],
|
||||||
|
isAsync: false,
|
||||||
|
visibility: "public",
|
||||||
|
isStatic: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: "testProp",
|
||||||
|
line: 2,
|
||||||
|
visibility: "private",
|
||||||
|
isStatic: false,
|
||||||
|
isReadonly: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
implements: [],
|
||||||
|
isExported: true,
|
||||||
|
isAbstract: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAST(classes: ClassInfo[] = []): FileAST {
|
||||||
|
return {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes,
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockStorage(
|
||||||
|
fileData: { lines: string[] } | null = null,
|
||||||
|
ast: FileAST | null = null,
|
||||||
|
): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn().mockResolvedValue(fileData),
|
||||||
|
setFile: vi.fn(),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getAST: vi.fn().mockResolvedValue(ast),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
setConfig: vi.fn(),
|
||||||
|
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("GetClassTool", () => {
|
||||||
|
let tool: GetClassTool
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new GetClassTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("get_class")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
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(true)
|
||||||
|
expect(tool.parameters[1].name).toBe("name")
|
||||||
|
expect(tool.parameters[1].required).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for valid params", () => {
|
||||||
|
expect(tool.validateParams({ path: "src/index.ts", name: "MyClass" })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing path", () => {
|
||||||
|
expect(tool.validateParams({ name: "MyClass" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty path", () => {
|
||||||
|
expect(tool.validateParams({ path: "", name: "MyClass" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing name", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts" })).toBe(
|
||||||
|
"Parameter 'name' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty name", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", name: "" })).toBe(
|
||||||
|
"Parameter 'name' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
it("should return class code with line numbers", async () => {
|
||||||
|
const lines = [
|
||||||
|
"export class TestClass {",
|
||||||
|
" private testProp: string",
|
||||||
|
" testMethod() {",
|
||||||
|
" return this.testProp",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
]
|
||||||
|
const cls = createMockClass({
|
||||||
|
name: "TestClass",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 6,
|
||||||
|
})
|
||||||
|
const ast = createMockAST([cls])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "TestClass" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetClassResult
|
||||||
|
expect(data.path).toBe("test.ts")
|
||||||
|
expect(data.name).toBe("TestClass")
|
||||||
|
expect(data.startLine).toBe(1)
|
||||||
|
expect(data.endLine).toBe(6)
|
||||||
|
expect(data.content).toContain("1│export class TestClass {")
|
||||||
|
expect(data.content).toContain("6│}")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return class metadata", async () => {
|
||||||
|
const lines = ["abstract class BaseService extends Service implements IService {", "}"]
|
||||||
|
const cls = createMockClass({
|
||||||
|
name: "BaseService",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 2,
|
||||||
|
isExported: false,
|
||||||
|
isAbstract: true,
|
||||||
|
extends: "Service",
|
||||||
|
implements: ["IService"],
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
name: "init",
|
||||||
|
lineStart: 2,
|
||||||
|
lineEnd: 2,
|
||||||
|
params: [],
|
||||||
|
isAsync: true,
|
||||||
|
visibility: "public",
|
||||||
|
isStatic: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "destroy",
|
||||||
|
lineStart: 3,
|
||||||
|
lineEnd: 3,
|
||||||
|
params: [],
|
||||||
|
isAsync: false,
|
||||||
|
visibility: "protected",
|
||||||
|
isStatic: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
line: 2,
|
||||||
|
visibility: "private",
|
||||||
|
isStatic: false,
|
||||||
|
isReadonly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const ast = createMockAST([cls])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "service.ts", name: "BaseService" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetClassResult
|
||||||
|
expect(data.isExported).toBe(false)
|
||||||
|
expect(data.isAbstract).toBe(true)
|
||||||
|
expect(data.extends).toBe("Service")
|
||||||
|
expect(data.implements).toEqual(["IService"])
|
||||||
|
expect(data.methods).toEqual(["init", "destroy"])
|
||||||
|
expect(data.properties).toEqual(["id"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when AST not found", async () => {
|
||||||
|
const storage = createMockStorage({ lines: [] }, null)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "MyClass" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('AST not found for "test.ts"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when class not found", async () => {
|
||||||
|
const ast = createMockAST([
|
||||||
|
createMockClass({ name: "ClassA" }),
|
||||||
|
createMockClass({ name: "ClassB" }),
|
||||||
|
])
|
||||||
|
const storage = createMockStorage({ lines: [] }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "NonExistent" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('Class "NonExistent" not found')
|
||||||
|
expect(result.error).toContain("Available: ClassA, ClassB")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when no classes available", async () => {
|
||||||
|
const ast = createMockAST([])
|
||||||
|
const storage = createMockStorage({ lines: [] }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "MyClass" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("Available: none")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle class with no extends", async () => {
|
||||||
|
const lines = ["class Simple {}"]
|
||||||
|
const cls = createMockClass({
|
||||||
|
name: "Simple",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 1,
|
||||||
|
extends: undefined,
|
||||||
|
})
|
||||||
|
const ast = createMockAST([cls])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "Simple" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetClassResult
|
||||||
|
expect(data.extends).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle class with empty implements", async () => {
|
||||||
|
const lines = ["class NoInterfaces {}"]
|
||||||
|
const cls = createMockClass({
|
||||||
|
name: "NoInterfaces",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 1,
|
||||||
|
implements: [],
|
||||||
|
})
|
||||||
|
const ast = createMockAST([cls])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "NoInterfaces" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetClassResult
|
||||||
|
expect(data.implements).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle class with no methods or properties", async () => {
|
||||||
|
const lines = ["class Empty {}"]
|
||||||
|
const cls = createMockClass({
|
||||||
|
name: "Empty",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 1,
|
||||||
|
methods: [],
|
||||||
|
properties: [],
|
||||||
|
})
|
||||||
|
const ast = createMockAST([cls])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "Empty" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetClassResult
|
||||||
|
expect(data.methods).toEqual([])
|
||||||
|
expect(data.properties).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const lines = ["class Test {}"]
|
||||||
|
const cls = createMockClass({ name: "Test", lineStart: 1, lineEnd: 1 })
|
||||||
|
const ast = createMockAST([cls])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "Test" }, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^get_class-\d+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import {
|
||||||
|
GetFunctionTool,
|
||||||
|
type GetFunctionResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/read/GetFunctionTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
import type { FileAST, FunctionInfo } from "../../../../../src/domain/value-objects/FileAST.js"
|
||||||
|
|
||||||
|
function createMockFunction(overrides: Partial<FunctionInfo> = {}): FunctionInfo {
|
||||||
|
return {
|
||||||
|
name: "testFunction",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 5,
|
||||||
|
params: [{ name: "arg1", optional: false, hasDefault: false }],
|
||||||
|
isAsync: false,
|
||||||
|
isExported: true,
|
||||||
|
returnType: "void",
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAST(functions: FunctionInfo[] = []): FileAST {
|
||||||
|
return {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions,
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockStorage(
|
||||||
|
fileData: { lines: string[] } | null = null,
|
||||||
|
ast: FileAST | null = null,
|
||||||
|
): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn().mockResolvedValue(fileData),
|
||||||
|
setFile: vi.fn(),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getAST: vi.fn().mockResolvedValue(ast),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
setConfig: vi.fn(),
|
||||||
|
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("GetFunctionTool", () => {
|
||||||
|
let tool: GetFunctionTool
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new GetFunctionTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("get_function")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
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(true)
|
||||||
|
expect(tool.parameters[1].name).toBe("name")
|
||||||
|
expect(tool.parameters[1].required).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for valid params", () => {
|
||||||
|
expect(tool.validateParams({ path: "src/index.ts", name: "myFunc" })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing path", () => {
|
||||||
|
expect(tool.validateParams({ name: "myFunc" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty path", () => {
|
||||||
|
expect(tool.validateParams({ path: "", name: "myFunc" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing name", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts" })).toBe(
|
||||||
|
"Parameter 'name' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty name", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", name: "" })).toBe(
|
||||||
|
"Parameter 'name' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for whitespace-only name", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", name: " " })).toBe(
|
||||||
|
"Parameter 'name' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
it("should return function code with line numbers", async () => {
|
||||||
|
const lines = [
|
||||||
|
"function testFunction(arg1) {",
|
||||||
|
" console.log(arg1)",
|
||||||
|
" return arg1",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
const func = createMockFunction({
|
||||||
|
name: "testFunction",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 4,
|
||||||
|
})
|
||||||
|
const ast = createMockAST([func])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "testFunction" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetFunctionResult
|
||||||
|
expect(data.path).toBe("test.ts")
|
||||||
|
expect(data.name).toBe("testFunction")
|
||||||
|
expect(data.startLine).toBe(1)
|
||||||
|
expect(data.endLine).toBe(4)
|
||||||
|
expect(data.content).toContain("1│function testFunction(arg1) {")
|
||||||
|
expect(data.content).toContain("4│}")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return function metadata", async () => {
|
||||||
|
const lines = ["async function fetchData(url, options) {", " return fetch(url)", "}"]
|
||||||
|
const func = createMockFunction({
|
||||||
|
name: "fetchData",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 3,
|
||||||
|
isAsync: true,
|
||||||
|
isExported: false,
|
||||||
|
params: [
|
||||||
|
{ name: "url", optional: false, hasDefault: false },
|
||||||
|
{ name: "options", optional: true, hasDefault: false },
|
||||||
|
],
|
||||||
|
returnType: "Promise<Response>",
|
||||||
|
})
|
||||||
|
const ast = createMockAST([func])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "api.ts", name: "fetchData" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetFunctionResult
|
||||||
|
expect(data.isAsync).toBe(true)
|
||||||
|
expect(data.isExported).toBe(false)
|
||||||
|
expect(data.params).toEqual(["url", "options"])
|
||||||
|
expect(data.returnType).toBe("Promise<Response>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when AST not found", async () => {
|
||||||
|
const storage = createMockStorage({ lines: [] }, null)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "myFunc" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('AST not found for "test.ts"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when function not found", async () => {
|
||||||
|
const ast = createMockAST([
|
||||||
|
createMockFunction({ name: "existingFunc" }),
|
||||||
|
createMockFunction({ name: "anotherFunc" }),
|
||||||
|
])
|
||||||
|
const storage = createMockStorage({ lines: [] }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "nonExistent" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('Function "nonExistent" not found')
|
||||||
|
expect(result.error).toContain("Available: existingFunc, anotherFunc")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when no functions available", async () => {
|
||||||
|
const ast = createMockAST([])
|
||||||
|
const storage = createMockStorage({ lines: [] }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "myFunc" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("Available: none")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should pad line numbers correctly for large files", async () => {
|
||||||
|
const lines = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`)
|
||||||
|
const func = createMockFunction({
|
||||||
|
name: "bigFunction",
|
||||||
|
lineStart: 95,
|
||||||
|
lineEnd: 105,
|
||||||
|
})
|
||||||
|
const ast = createMockAST([func])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "big.ts", name: "bigFunction" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetFunctionResult
|
||||||
|
expect(data.content).toContain(" 95│line 95")
|
||||||
|
expect(data.content).toContain("100│line 100")
|
||||||
|
expect(data.content).toContain("105│line 105")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const lines = ["function test() {}"]
|
||||||
|
const func = createMockFunction({ name: "test", lineStart: 1, lineEnd: 1 })
|
||||||
|
const ast = createMockAST([func])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "test" }, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^get_function-\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle function with no return type", async () => {
|
||||||
|
const lines = ["function noReturn() {}"]
|
||||||
|
const func = createMockFunction({
|
||||||
|
name: "noReturn",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 1,
|
||||||
|
returnType: undefined,
|
||||||
|
})
|
||||||
|
const ast = createMockAST([func])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "noReturn" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetFunctionResult
|
||||||
|
expect(data.returnType).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle function with no params", async () => {
|
||||||
|
const lines = ["function noParams() {}"]
|
||||||
|
const func = createMockFunction({
|
||||||
|
name: "noParams",
|
||||||
|
lineStart: 1,
|
||||||
|
lineEnd: 1,
|
||||||
|
params: [],
|
||||||
|
})
|
||||||
|
const ast = createMockAST([func])
|
||||||
|
const storage = createMockStorage({ lines }, ast)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", name: "noParams" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetFunctionResult
|
||||||
|
expect(data.params).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import {
|
||||||
|
GetLinesTool,
|
||||||
|
type GetLinesResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/read/GetLinesTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
|
||||||
|
function createMockStorage(fileData: { lines: string[] } | null = null): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn().mockResolvedValue(fileData),
|
||||||
|
setFile: vi.fn(),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getAST: vi.fn(),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
setConfig: vi.fn(),
|
||||||
|
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("GetLinesTool", () => {
|
||||||
|
let tool: GetLinesTool
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new GetLinesTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("get_lines")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not require confirmation", () => {
|
||||||
|
expect(tool.requiresConfirmation).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct parameters", () => {
|
||||||
|
expect(tool.parameters).toHaveLength(3)
|
||||||
|
expect(tool.parameters[0].name).toBe("path")
|
||||||
|
expect(tool.parameters[0].required).toBe(true)
|
||||||
|
expect(tool.parameters[1].name).toBe("start")
|
||||||
|
expect(tool.parameters[1].required).toBe(false)
|
||||||
|
expect(tool.parameters[2].name).toBe("end")
|
||||||
|
expect(tool.parameters[2].required).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for valid params with path only", () => {
|
||||||
|
expect(tool.validateParams({ path: "src/index.ts" })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return null for valid params with start and end", () => {
|
||||||
|
expect(tool.validateParams({ path: "src/index.ts", start: 1, end: 10 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for missing path", () => {
|
||||||
|
expect(tool.validateParams({})).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for empty path", () => {
|
||||||
|
expect(tool.validateParams({ path: "" })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: " " })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-string path", () => {
|
||||||
|
expect(tool.validateParams({ path: 123 })).toBe(
|
||||||
|
"Parameter 'path' is required and must be a non-empty string",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-integer start", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 1.5 })).toBe(
|
||||||
|
"Parameter 'start' must be an integer",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: "1" })).toBe(
|
||||||
|
"Parameter 'start' must be an integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for start < 1", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 0 })).toBe(
|
||||||
|
"Parameter 'start' must be >= 1",
|
||||||
|
)
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: -1 })).toBe(
|
||||||
|
"Parameter 'start' must be >= 1",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-integer end", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", end: 1.5 })).toBe(
|
||||||
|
"Parameter 'end' must be an integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for end < 1", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", end: 0 })).toBe(
|
||||||
|
"Parameter 'end' must be >= 1",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for start > end", () => {
|
||||||
|
expect(tool.validateParams({ path: "test.ts", start: 10, end: 5 })).toBe(
|
||||||
|
"Parameter 'start' must be <= 'end'",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
it("should return all lines when no range specified", async () => {
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const storage = createMockStorage({ lines })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.path).toBe("test.ts")
|
||||||
|
expect(data.startLine).toBe(1)
|
||||||
|
expect(data.endLine).toBe(3)
|
||||||
|
expect(data.totalLines).toBe(3)
|
||||||
|
expect(data.content).toContain("1│line 1")
|
||||||
|
expect(data.content).toContain("2│line 2")
|
||||||
|
expect(data.content).toContain("3│line 3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return specific range", async () => {
|
||||||
|
const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
|
||||||
|
const storage = createMockStorage({ lines })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", start: 2, end: 4 }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.startLine).toBe(2)
|
||||||
|
expect(data.endLine).toBe(4)
|
||||||
|
expect(data.content).toContain("2│line 2")
|
||||||
|
expect(data.content).toContain("3│line 3")
|
||||||
|
expect(data.content).toContain("4│line 4")
|
||||||
|
expect(data.content).not.toContain("line 1")
|
||||||
|
expect(data.content).not.toContain("line 5")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should clamp start to 1 if less", async () => {
|
||||||
|
const lines = ["line 1", "line 2"]
|
||||||
|
const storage = createMockStorage({ lines })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", start: -5, end: 2 }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.startLine).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should clamp end to totalLines if greater", async () => {
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const storage = createMockStorage({ lines })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", start: 1, end: 100 }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.endLine).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should pad line numbers correctly", async () => {
|
||||||
|
const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`)
|
||||||
|
const storage = createMockStorage({ lines })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts", start: 98, end: 100 }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.content).toContain(" 98│line 98")
|
||||||
|
expect(data.content).toContain(" 99│line 99")
|
||||||
|
expect(data.content).toContain("100│line 100")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error when file not found", async () => {
|
||||||
|
const storage = createMockStorage(null)
|
||||||
|
storage.getFile = vi.fn().mockResolvedValue(null)
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("ENOENT")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const storage = createMockStorage({ lines: ["test"] })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^get_lines-\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include executionTimeMs in result", async () => {
|
||||||
|
const storage = createMockStorage({ lines: ["test"] })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty file", async () => {
|
||||||
|
const storage = createMockStorage({ lines: [] })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "empty.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.totalLines).toBe(0)
|
||||||
|
expect(data.content).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle single line file", async () => {
|
||||||
|
const storage = createMockStorage({ lines: ["only line"] })
|
||||||
|
const ctx = createMockContext(storage)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "single.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetLinesResult
|
||||||
|
expect(data.totalLines).toBe(1)
|
||||||
|
expect(data.content).toBe("1│only line")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import * as os from "node:os"
|
||||||
|
import {
|
||||||
|
GetStructureTool,
|
||||||
|
type GetStructureResult,
|
||||||
|
} from "../../../../../src/infrastructure/tools/read/GetStructureTool.js"
|
||||||
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||||
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||||
|
|
||||||
|
function createMockStorage(): IStorage {
|
||||||
|
return {
|
||||||
|
getFile: vi.fn(),
|
||||||
|
setFile: vi.fn(),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
getAllFiles: vi.fn(),
|
||||||
|
getAST: vi.fn(),
|
||||||
|
setAST: vi.fn(),
|
||||||
|
getMeta: vi.fn(),
|
||||||
|
setMeta: vi.fn(),
|
||||||
|
getSymbolIndex: vi.fn(),
|
||||||
|
setSymbolIndex: vi.fn(),
|
||||||
|
getDepsGraph: vi.fn(),
|
||||||
|
setDepsGraph: vi.fn(),
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
setConfig: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
} as unknown as IStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContext(projectRoot: string): ToolContext {
|
||||||
|
return {
|
||||||
|
projectRoot,
|
||||||
|
storage: createMockStorage(),
|
||||||
|
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GetStructureTool", () => {
|
||||||
|
let tool: GetStructureTool
|
||||||
|
let tempDir: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tool = new GetStructureTool()
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ipuaro-test-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("get_structure")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
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("depth")
|
||||||
|
expect(tool.parameters[1].required).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateParams", () => {
|
||||||
|
it("should return null for empty params", () => {
|
||||||
|
expect(tool.validateParams({})).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return null for valid path", () => {
|
||||||
|
expect(tool.validateParams({ path: "src" })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return null for valid depth", () => {
|
||||||
|
expect(tool.validateParams({ depth: 3 })).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 depth", () => {
|
||||||
|
expect(tool.validateParams({ depth: 2.5 })).toBe("Parameter 'depth' must be an integer")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for depth < 1", () => {
|
||||||
|
expect(tool.validateParams({ depth: 0 })).toBe("Parameter 'depth' must be >= 1")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
it("should return tree structure for empty directory", async () => {
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.path).toBe(".")
|
||||||
|
expect(data.tree.type).toBe("directory")
|
||||||
|
expect(data.tree.children).toEqual([])
|
||||||
|
expect(data.stats.directories).toBe(1)
|
||||||
|
expect(data.stats.files).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return tree structure with files", async () => {
|
||||||
|
await fs.writeFile(path.join(tempDir, "file1.ts"), "")
|
||||||
|
await fs.writeFile(path.join(tempDir, "file2.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.tree.children).toHaveLength(2)
|
||||||
|
expect(data.stats.files).toBe(2)
|
||||||
|
expect(data.content).toContain("file1.ts")
|
||||||
|
expect(data.content).toContain("file2.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return nested directory structure", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, "src"))
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "index.ts"), "")
|
||||||
|
await fs.mkdir(path.join(tempDir, "src", "utils"))
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "utils", "helper.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.stats.directories).toBe(3)
|
||||||
|
expect(data.stats.files).toBe(2)
|
||||||
|
expect(data.content).toContain("src")
|
||||||
|
expect(data.content).toContain("index.ts")
|
||||||
|
expect(data.content).toContain("utils")
|
||||||
|
expect(data.content).toContain("helper.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect depth parameter", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, "level1"))
|
||||||
|
await fs.mkdir(path.join(tempDir, "level1", "level2"))
|
||||||
|
await fs.mkdir(path.join(tempDir, "level1", "level2", "level3"))
|
||||||
|
await fs.writeFile(path.join(tempDir, "level1", "level2", "level3", "deep.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ depth: 2 }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.content).toContain("level1")
|
||||||
|
expect(data.content).toContain("level2")
|
||||||
|
expect(data.content).not.toContain("level3")
|
||||||
|
expect(data.content).not.toContain("deep.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter subdirectory when path specified", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, "src"))
|
||||||
|
await fs.mkdir(path.join(tempDir, "tests"))
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "index.ts"), "")
|
||||||
|
await fs.writeFile(path.join(tempDir, "tests", "test.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "src" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.path).toBe("src")
|
||||||
|
expect(data.content).toContain("index.ts")
|
||||||
|
expect(data.content).not.toContain("test.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should ignore node_modules", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, "node_modules"))
|
||||||
|
await fs.writeFile(path.join(tempDir, "node_modules", "pkg.js"), "")
|
||||||
|
await fs.writeFile(path.join(tempDir, "index.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.content).not.toContain("node_modules")
|
||||||
|
expect(data.content).toContain("index.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should ignore .git directory", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, ".git"))
|
||||||
|
await fs.writeFile(path.join(tempDir, ".git", "config"), "")
|
||||||
|
await fs.writeFile(path.join(tempDir, "index.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.content).not.toContain(".git")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should sort directories before files", async () => {
|
||||||
|
await fs.writeFile(path.join(tempDir, "aaa.ts"), "")
|
||||||
|
await fs.mkdir(path.join(tempDir, "zzz"))
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
const zzzIndex = data.content.indexOf("zzz")
|
||||||
|
const aaaIndex = data.content.indexOf("aaa.ts")
|
||||||
|
expect(zzzIndex).toBeLessThan(aaaIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for path outside project root", async () => {
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "../outside" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Path must be within project root")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-directory path", async () => {
|
||||||
|
await fs.writeFile(path.join(tempDir, "file.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("is not a directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error for non-existent path", async () => {
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({ path: "nonexistent" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain("ENOENT")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^get_structure-\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use tree icons in output", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, "src"))
|
||||||
|
await fs.writeFile(path.join(tempDir, "index.ts"), "")
|
||||||
|
const ctx = createMockContext(tempDir)
|
||||||
|
|
||||||
|
const result = await tool.execute({}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
const data = result.data as GetStructureResult
|
||||||
|
expect(data.content).toContain("📁")
|
||||||
|
expect(data.content).toContain("📄")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
449
packages/ipuaro/tests/unit/infrastructure/tools/registry.test.ts
Normal file
449
packages/ipuaro/tests/unit/infrastructure/tools/registry.test.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import { ToolRegistry } from "../../../../src/infrastructure/tools/registry.js"
|
||||||
|
import type {
|
||||||
|
ITool,
|
||||||
|
ToolContext,
|
||||||
|
ToolParameterSchema,
|
||||||
|
} from "../../../../src/domain/services/ITool.js"
|
||||||
|
import type { ToolResult } from "../../../../src/domain/value-objects/ToolResult.js"
|
||||||
|
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock tool for testing.
|
||||||
|
*/
|
||||||
|
function createMockTool(overrides: Partial<ITool> = {}): ITool {
|
||||||
|
return {
|
||||||
|
name: "mock_tool",
|
||||||
|
description: "A mock tool for testing",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
type: "string",
|
||||||
|
description: "File path",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional",
|
||||||
|
type: "number",
|
||||||
|
description: "Optional param",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requiresConfirmation: false,
|
||||||
|
category: "read",
|
||||||
|
execute: vi.fn().mockResolvedValue({
|
||||||
|
callId: "test-123",
|
||||||
|
success: true,
|
||||||
|
data: { result: "success" },
|
||||||
|
executionTimeMs: 10,
|
||||||
|
}),
|
||||||
|
validateParams: vi.fn().mockReturnValue(null),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock tool context for testing.
|
||||||
|
*/
|
||||||
|
function createMockContext(overrides: Partial<ToolContext> = {}): ToolContext {
|
||||||
|
return {
|
||||||
|
projectRoot: "/test/project",
|
||||||
|
storage: {} as ToolContext["storage"],
|
||||||
|
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ToolRegistry", () => {
|
||||||
|
let registry: ToolRegistry
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ToolRegistry()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("register", () => {
|
||||||
|
it("should register a tool", () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
expect(registry.has("mock_tool")).toBe(true)
|
||||||
|
expect(registry.size).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should register multiple tools", () => {
|
||||||
|
const tool1 = createMockTool({ name: "tool_1" })
|
||||||
|
const tool2 = createMockTool({ name: "tool_2" })
|
||||||
|
|
||||||
|
registry.register(tool1)
|
||||||
|
registry.register(tool2)
|
||||||
|
|
||||||
|
expect(registry.size).toBe(2)
|
||||||
|
expect(registry.has("tool_1")).toBe(true)
|
||||||
|
expect(registry.has("tool_2")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw error when registering duplicate tool name", () => {
|
||||||
|
const tool1 = createMockTool({ name: "duplicate" })
|
||||||
|
const tool2 = createMockTool({ name: "duplicate" })
|
||||||
|
|
||||||
|
registry.register(tool1)
|
||||||
|
|
||||||
|
expect(() => registry.register(tool2)).toThrow(IpuaroError)
|
||||||
|
expect(() => registry.register(tool2)).toThrow('Tool "duplicate" is already registered')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("unregister", () => {
|
||||||
|
it("should remove a registered tool", () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
const result = registry.unregister("mock_tool")
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(registry.has("mock_tool")).toBe(false)
|
||||||
|
expect(registry.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false when tool not found", () => {
|
||||||
|
const result = registry.unregister("nonexistent")
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("get", () => {
|
||||||
|
it("should return registered tool", () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
const result = registry.get("mock_tool")
|
||||||
|
|
||||||
|
expect(result).toBe(tool)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined for unknown tool", () => {
|
||||||
|
const result = registry.get("unknown")
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getAll", () => {
|
||||||
|
it("should return empty array when no tools registered", () => {
|
||||||
|
const result = registry.getAll()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return all registered tools", () => {
|
||||||
|
const tool1 = createMockTool({ name: "tool_1" })
|
||||||
|
const tool2 = createMockTool({ name: "tool_2" })
|
||||||
|
registry.register(tool1)
|
||||||
|
registry.register(tool2)
|
||||||
|
|
||||||
|
const result = registry.getAll()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result).toContain(tool1)
|
||||||
|
expect(result).toContain(tool2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getByCategory", () => {
|
||||||
|
it("should return tools by category", () => {
|
||||||
|
const readTool = createMockTool({ name: "read_tool", category: "read" })
|
||||||
|
const editTool = createMockTool({ name: "edit_tool", category: "edit" })
|
||||||
|
const gitTool = createMockTool({ name: "git_tool", category: "git" })
|
||||||
|
registry.register(readTool)
|
||||||
|
registry.register(editTool)
|
||||||
|
registry.register(gitTool)
|
||||||
|
|
||||||
|
const readTools = registry.getByCategory("read")
|
||||||
|
const editTools = registry.getByCategory("edit")
|
||||||
|
|
||||||
|
expect(readTools).toHaveLength(1)
|
||||||
|
expect(readTools[0]).toBe(readTool)
|
||||||
|
expect(editTools).toHaveLength(1)
|
||||||
|
expect(editTools[0]).toBe(editTool)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for category with no tools", () => {
|
||||||
|
const readTool = createMockTool({ category: "read" })
|
||||||
|
registry.register(readTool)
|
||||||
|
|
||||||
|
const result = registry.getByCategory("analysis")
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("has", () => {
|
||||||
|
it("should return true for registered tool", () => {
|
||||||
|
registry.register(createMockTool())
|
||||||
|
|
||||||
|
expect(registry.has("mock_tool")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for unknown tool", () => {
|
||||||
|
expect(registry.has("unknown")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
it("should execute tool and return result", async () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).toEqual({ result: "success" })
|
||||||
|
expect(tool.execute).toHaveBeenCalledWith({ path: "test.ts" }, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error result for unknown tool", async () => {
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await registry.execute("unknown", {}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Tool "unknown" not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error result when validation fails", async () => {
|
||||||
|
const tool = createMockTool({
|
||||||
|
validateParams: vi.fn().mockReturnValue("Missing required param: path"),
|
||||||
|
})
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await registry.execute("mock_tool", {}, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Missing required param: path")
|
||||||
|
expect(tool.execute).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should request confirmation for tools that require it", async () => {
|
||||||
|
const tool = createMockTool({ requiresConfirmation: true })
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||||
|
expect(tool.execute).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not execute when confirmation is denied", async () => {
|
||||||
|
const tool = createMockTool({ requiresConfirmation: true })
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext({
|
||||||
|
requestConfirmation: vi.fn().mockResolvedValue(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("User cancelled operation")
|
||||||
|
expect(tool.execute).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not request confirmation for safe tools", async () => {
|
||||||
|
const tool = createMockTool({ requiresConfirmation: false })
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||||
|
expect(tool.execute).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should catch and return errors from tool execution", async () => {
|
||||||
|
const tool = createMockTool({
|
||||||
|
execute: vi.fn().mockRejectedValue(new Error("Execution failed")),
|
||||||
|
})
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Execution failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include callId in result", async () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
registry.register(tool)
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
|
||||||
|
expect(result.callId).toMatch(/^mock_tool-\d+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getToolDefinitions", () => {
|
||||||
|
it("should return empty array when no tools registered", () => {
|
||||||
|
const result = registry.getToolDefinitions()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert tools to LLM-compatible format", () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
const result = registry.getToolDefinitions()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
name: "mock_tool",
|
||||||
|
description: "A mock tool for testing",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: "string",
|
||||||
|
description: "File path",
|
||||||
|
},
|
||||||
|
optional: {
|
||||||
|
type: "number",
|
||||||
|
description: "Optional param",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle tools with no parameters", () => {
|
||||||
|
const tool = createMockTool({ parameters: [] })
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
const result = registry.getToolDefinitions()
|
||||||
|
|
||||||
|
expect(result[0].parameters).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple tools", () => {
|
||||||
|
registry.register(createMockTool({ name: "tool_1" }))
|
||||||
|
registry.register(createMockTool({ name: "tool_2" }))
|
||||||
|
|
||||||
|
const result = registry.getToolDefinitions()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((t) => t.name)).toEqual(["tool_1", "tool_2"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("clear", () => {
|
||||||
|
it("should remove all tools", () => {
|
||||||
|
registry.register(createMockTool({ name: "tool_1" }))
|
||||||
|
registry.register(createMockTool({ name: "tool_2" }))
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
expect(registry.size).toBe(0)
|
||||||
|
expect(registry.getAll()).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getNames", () => {
|
||||||
|
it("should return all tool names", () => {
|
||||||
|
registry.register(createMockTool({ name: "alpha" }))
|
||||||
|
registry.register(createMockTool({ name: "beta" }))
|
||||||
|
|
||||||
|
const result = registry.getNames()
|
||||||
|
|
||||||
|
expect(result).toEqual(["alpha", "beta"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array when no tools", () => {
|
||||||
|
const result = registry.getNames()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getConfirmationTools", () => {
|
||||||
|
it("should return only tools requiring confirmation", () => {
|
||||||
|
registry.register(createMockTool({ name: "safe", requiresConfirmation: false }))
|
||||||
|
registry.register(createMockTool({ name: "dangerous", requiresConfirmation: true }))
|
||||||
|
registry.register(createMockTool({ name: "also_safe", requiresConfirmation: false }))
|
||||||
|
|
||||||
|
const result = registry.getConfirmationTools()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].name).toBe("dangerous")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getSafeTools", () => {
|
||||||
|
it("should return only tools not requiring confirmation", () => {
|
||||||
|
registry.register(createMockTool({ name: "safe", requiresConfirmation: false }))
|
||||||
|
registry.register(createMockTool({ name: "dangerous", requiresConfirmation: true }))
|
||||||
|
registry.register(createMockTool({ name: "also_safe", requiresConfirmation: false }))
|
||||||
|
|
||||||
|
const result = registry.getSafeTools()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((t) => t.name)).toEqual(["safe", "also_safe"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("size", () => {
|
||||||
|
it("should return 0 for empty registry", () => {
|
||||||
|
expect(registry.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return correct count", () => {
|
||||||
|
registry.register(createMockTool({ name: "a" }))
|
||||||
|
registry.register(createMockTool({ name: "b" }))
|
||||||
|
registry.register(createMockTool({ name: "c" }))
|
||||||
|
|
||||||
|
expect(registry.size).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("integration scenarios", () => {
|
||||||
|
it("should handle full workflow: register, execute, unregister", async () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
const ctx = createMockContext()
|
||||||
|
|
||||||
|
registry.register(tool)
|
||||||
|
expect(registry.has("mock_tool")).toBe(true)
|
||||||
|
|
||||||
|
const result = await registry.execute("mock_tool", { path: "test.ts" }, ctx)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
registry.unregister("mock_tool")
|
||||||
|
expect(registry.has("mock_tool")).toBe(false)
|
||||||
|
|
||||||
|
const afterUnregister = await registry.execute("mock_tool", {}, ctx)
|
||||||
|
expect(afterUnregister.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should maintain isolation between registrations", () => {
|
||||||
|
const registry1 = new ToolRegistry()
|
||||||
|
const registry2 = new ToolRegistry()
|
||||||
|
|
||||||
|
registry1.register(createMockTool({ name: "tool_1" }))
|
||||||
|
registry2.register(createMockTool({ name: "tool_2" }))
|
||||||
|
|
||||||
|
expect(registry1.has("tool_1")).toBe(true)
|
||||||
|
expect(registry1.has("tool_2")).toBe(false)
|
||||||
|
expect(registry2.has("tool_1")).toBe(false)
|
||||||
|
expect(registry2.has("tool_2")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,10 +11,10 @@ export default defineConfig({
|
|||||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"],
|
exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 80,
|
lines: 95,
|
||||||
functions: 80,
|
functions: 95,
|
||||||
branches: 80,
|
branches: 90,
|
||||||
statements: 80,
|
statements: 95,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user