mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add read tools (v0.5.0)
- ToolRegistry: tool lifecycle management, execution with validation - GetLinesTool: read file lines with line numbers - GetFunctionTool: get function source using AST - GetClassTool: get class source using AST - GetStructureTool: directory tree with filtering 121 new tests, 540 total
This commit is contained in:
@@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samiyev/ipuaro",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export * from "./storage/index.js"
|
||||
export * from "./indexer/index.js"
|
||||
export * from "./llm/index.js"
|
||||
export * from "./tools/index.js"
|
||||
|
||||
12
packages/ipuaro/src/infrastructure/tools/index.ts
Normal file
12
packages/ipuaro/src/infrastructure/tools/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// 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"
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -127,9 +127,7 @@ describe("ResponseParser", () => {
|
||||
|
||||
describe("formatToolCallsAsXml", () => {
|
||||
it("should format tool calls as XML", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 }),
|
||||
]
|
||||
const toolCalls = [createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 })]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
@@ -152,9 +150,7 @@ describe("ResponseParser", () => {
|
||||
})
|
||||
|
||||
it("should handle object values as JSON", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "test", { data: { key: "value" } }),
|
||||
]
|
||||
const toolCalls = [createToolCall("1", "test", { data: { key: "value" } })]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user