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:
imfozilbek
2025-12-01 00:52:00 +05:00
parent 68f927d906
commit 25146003cc
15 changed files with 2592 additions and 7 deletions

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

View 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")
}
}

View 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")
}
}

View 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")
}
}

View File

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

View 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)
}
}