mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
Compare commits
5 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56643d903f | ||
|
|
f5f904a847 | ||
|
|
2ae1ac13f5 | ||
|
|
caf7aac116 | ||
|
|
4ad5a209c4 |
@@ -5,6 +5,179 @@ 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.9.0] - 2025-12-01 - Git & Run Tools
|
||||
|
||||
### Added
|
||||
|
||||
- **GitStatusTool (0.9.1)**
|
||||
- `git_status()`: Get current git repository status
|
||||
- Returns branch name, tracking branch, ahead/behind counts
|
||||
- Lists staged, modified, untracked, and conflicted files
|
||||
- Detects detached HEAD state
|
||||
- 29 unit tests
|
||||
|
||||
- **GitDiffTool (0.9.2)**
|
||||
- `git_diff(path?, staged?)`: Get uncommitted changes
|
||||
- Returns file-by-file diff summary with insertions/deletions
|
||||
- Full diff text output
|
||||
- Optional path filter for specific files/directories
|
||||
- Staged-only mode (`--cached`)
|
||||
- Handles binary files
|
||||
- 25 unit tests
|
||||
|
||||
- **GitCommitTool (0.9.3)**
|
||||
- `git_commit(message, files?)`: Create a git commit
|
||||
- Requires user confirmation before commit
|
||||
- Optional file staging before commit
|
||||
- Returns commit hash, summary, author info
|
||||
- Validates staged files exist
|
||||
- 26 unit tests
|
||||
|
||||
- **CommandSecurity**
|
||||
- Security module for shell command validation
|
||||
- Blacklist: dangerous commands always blocked (rm -rf, sudo, git push --force, etc.)
|
||||
- Whitelist: safe commands allowed without confirmation (npm, node, git status, etc.)
|
||||
- Classification: `allowed`, `blocked`, `requires_confirmation`
|
||||
- Git subcommand awareness (safe read operations vs write operations)
|
||||
- Extensible via `addToBlacklist()` and `addToWhitelist()`
|
||||
- 65 unit tests
|
||||
|
||||
- **RunCommandTool (0.9.4)**
|
||||
- `run_command(command, timeout?)`: Execute shell commands
|
||||
- Security-first design with blacklist/whitelist checks
|
||||
- Blocked commands rejected immediately
|
||||
- Unknown commands require user confirmation
|
||||
- Configurable timeout (default 30s, max 10min)
|
||||
- Output truncation for large outputs
|
||||
- Returns stdout, stderr, exit code, duration
|
||||
- 40 unit tests
|
||||
|
||||
- **RunTestsTool (0.9.5)**
|
||||
- `run_tests(path?, filter?, watch?)`: Run project tests
|
||||
- Auto-detects test runner: vitest, jest, mocha, npm test
|
||||
- Detects by config files and package.json dependencies
|
||||
- Path filtering for specific test files/directories
|
||||
- Name pattern filtering (`-t` / `--grep`)
|
||||
- Watch mode support
|
||||
- Returns pass/fail status, exit code, output
|
||||
- 48 unit tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Total tests: 1086 (was 853)
|
||||
- Coverage: 98.08% lines, 92.21% branches
|
||||
- Git tools category now fully implemented (3/3 tools)
|
||||
- Run tools category now fully implemented (2/2 tools)
|
||||
- All 18 planned tools now implemented
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] - 2025-12-01 - Analysis Tools
|
||||
|
||||
### Added
|
||||
|
||||
- **GetDependenciesTool (0.8.1)**
|
||||
- `get_dependencies(path)`: Get files that a specific file imports
|
||||
- Returns internal dependencies resolved to file paths
|
||||
- Includes metadata: exists, isHub, isEntryPoint, fileType
|
||||
- Sorted by path for consistent output
|
||||
- 23 unit tests
|
||||
|
||||
- **GetDependentsTool (0.8.2)**
|
||||
- `get_dependents(path)`: Get files that import a specific file
|
||||
- Shows hub status for the analyzed file
|
||||
- Includes metadata: isHub, isEntryPoint, fileType, complexityScore
|
||||
- Sorted by path for consistent output
|
||||
- 24 unit tests
|
||||
|
||||
- **GetComplexityTool (0.8.3)**
|
||||
- `get_complexity(path?, limit?)`: Get complexity metrics for files
|
||||
- Returns LOC, nesting depth, cyclomatic complexity, and overall score
|
||||
- Summary statistics: high/medium/low complexity counts
|
||||
- Average score calculation
|
||||
- Sorted by complexity score descending
|
||||
- Default limit of 20 files
|
||||
- 31 unit tests
|
||||
|
||||
- **GetTodosTool (0.8.4)**
|
||||
- `get_todos(path?, type?)`: Find TODO/FIXME/HACK/XXX/BUG/NOTE comments
|
||||
- Supports multiple comment styles: `//`, `/* */`, `#`
|
||||
- Filter by type (case-insensitive)
|
||||
- Counts by type
|
||||
- Includes line context
|
||||
- 42 unit tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Total tests: 853 (was 733)
|
||||
- Coverage: 97.91% lines, 92.32% branches
|
||||
- Analysis tools category now fully implemented (4/4 tools)
|
||||
|
||||
---
|
||||
|
||||
## [0.7.0] - 2025-12-01 - Search Tools
|
||||
|
||||
### Added
|
||||
|
||||
- **FindReferencesTool (0.7.1)**
|
||||
- `find_references(symbol, path?)`: Find all usages of a symbol across the codebase
|
||||
- Word boundary matching with support for special characters (e.g., `$value`)
|
||||
- Context lines around each reference (1 line before/after)
|
||||
- Marks definition vs usage references
|
||||
- Optional path filter for scoped searches
|
||||
- Returns: path, line, column, context, isDefinition
|
||||
- 37 unit tests
|
||||
|
||||
- **FindDefinitionTool (0.7.2)**
|
||||
- `find_definition(symbol)`: Find where a symbol is defined
|
||||
- Uses SymbolIndex for fast lookups
|
||||
- Returns multiple definitions (for overloads/re-exports)
|
||||
- Suggests similar symbols when not found (Levenshtein distance)
|
||||
- Context lines around definition (2 lines before/after)
|
||||
- Returns: path, line, type, context
|
||||
- 32 unit tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Total tests: 733 (was 664)
|
||||
- Coverage: 97.71% lines, 91.84% branches
|
||||
- Search tools category now fully implemented (2/2 tools)
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0] - 2025-12-01 - Edit Tools
|
||||
|
||||
### Added
|
||||
|
||||
- **EditLinesTool (0.6.1)**
|
||||
- `edit_lines(path, start, end, content)`: Replace lines in a file
|
||||
- Hash conflict detection (prevents editing externally modified files)
|
||||
- Confirmation required with diff preview
|
||||
- Automatic storage update after edit
|
||||
- 35 unit tests
|
||||
|
||||
- **CreateFileTool (0.6.2)**
|
||||
- `create_file(path, content)`: Create new file with content
|
||||
- Automatic directory creation if needed
|
||||
- Path validation (must be within project root)
|
||||
- Prevents overwriting existing files
|
||||
- Confirmation required before creation
|
||||
- 26 unit tests
|
||||
|
||||
- **DeleteFileTool (0.6.3)**
|
||||
- `delete_file(path)`: Delete file from filesystem and storage
|
||||
- Removes file data, AST, and meta from Redis
|
||||
- Confirmation required with file content preview
|
||||
- 20 unit tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Total tests: 664 (was 540)
|
||||
- Coverage: 97.71% lines, 91.89% branches
|
||||
- Coverage thresholds: 95% lines/functions/statements, 90% branches
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] - 2025-12-01 - Read Tools
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samiyev/ipuaro",
|
||||
"version": "0.5.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import type { ComplexityMetrics, FileMeta } from "../../../domain/value-objects/FileMeta.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Complexity entry for a single file.
|
||||
*/
|
||||
export interface ComplexityEntry {
|
||||
/** Relative path to the file */
|
||||
path: string
|
||||
/** Complexity metrics */
|
||||
metrics: ComplexityMetrics
|
||||
/** File type classification */
|
||||
fileType: "source" | "test" | "config" | "types" | "unknown"
|
||||
/** Whether the file is a hub */
|
||||
isHub: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from get_complexity tool.
|
||||
*/
|
||||
export interface GetComplexityResult {
|
||||
/** The path that was analyzed (file or directory) */
|
||||
analyzedPath: string | null
|
||||
/** Total files analyzed */
|
||||
totalFiles: number
|
||||
/** Average complexity score */
|
||||
averageScore: number
|
||||
/** Files sorted by complexity score (descending) */
|
||||
files: ComplexityEntry[]
|
||||
/** Summary statistics */
|
||||
summary: {
|
||||
highComplexity: number
|
||||
mediumComplexity: number
|
||||
lowComplexity: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complexity thresholds for classification.
|
||||
*/
|
||||
const COMPLEXITY_THRESHOLDS = {
|
||||
high: 60,
|
||||
medium: 30,
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting complexity metrics for files.
|
||||
* Can analyze a single file or all files in the project.
|
||||
*/
|
||||
export class GetComplexityTool implements ITool {
|
||||
readonly name = "get_complexity"
|
||||
readonly description =
|
||||
"Get complexity metrics for files. " +
|
||||
"Returns LOC, nesting depth, cyclomatic complexity, and overall score. " +
|
||||
"Without path, returns all files sorted by complexity."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File or directory path to analyze (optional, defaults to entire project)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "limit",
|
||||
type: "number",
|
||||
description: "Maximum number of files to return (default: 20)",
|
||||
required: false,
|
||||
default: 20,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "analysis" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
if (params.limit !== undefined) {
|
||||
if (typeof params.limit !== "number" || !Number.isInteger(params.limit)) {
|
||||
return "Parameter 'limit' must be an integer"
|
||||
}
|
||||
if (params.limit < 1) {
|
||||
return "Parameter 'limit' must be at least 1"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const inputPath = params.path as string | undefined
|
||||
const limit = (params.limit as number | undefined) ?? 20
|
||||
|
||||
try {
|
||||
const allMetas = await ctx.storage.getAllMetas()
|
||||
|
||||
if (allMetas.size === 0) {
|
||||
return createSuccessResult(
|
||||
callId,
|
||||
{
|
||||
analyzedPath: inputPath ?? null,
|
||||
totalFiles: 0,
|
||||
averageScore: 0,
|
||||
files: [],
|
||||
summary: { highComplexity: 0, mediumComplexity: 0, lowComplexity: 0 },
|
||||
} satisfies GetComplexityResult,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
let filteredMetas = allMetas
|
||||
let analyzedPath: string | null = null
|
||||
|
||||
if (inputPath) {
|
||||
const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot)
|
||||
analyzedPath = relativePath
|
||||
filteredMetas = this.filterByPath(allMetas, relativePath)
|
||||
|
||||
if (filteredMetas.size === 0) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`No files found at path: ${relativePath}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const entries: ComplexityEntry[] = []
|
||||
for (const [filePath, meta] of filteredMetas) {
|
||||
entries.push({
|
||||
path: filePath,
|
||||
metrics: meta.complexity,
|
||||
fileType: meta.fileType,
|
||||
isHub: meta.isHub,
|
||||
})
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.metrics.score - a.metrics.score)
|
||||
|
||||
const summary = this.calculateSummary(entries)
|
||||
const averageScore = this.calculateAverageScore(entries)
|
||||
|
||||
const limitedEntries = entries.slice(0, limit)
|
||||
|
||||
const result: GetComplexityResult = {
|
||||
analyzedPath,
|
||||
totalFiles: entries.length,
|
||||
averageScore,
|
||||
files: limitedEntries,
|
||||
summary,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize input path to relative path from project root.
|
||||
*/
|
||||
private normalizePathToRelative(inputPath: string, projectRoot: string): string {
|
||||
if (path.isAbsolute(inputPath)) {
|
||||
return path.relative(projectRoot, inputPath)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter metas by path prefix (file or directory).
|
||||
*/
|
||||
private filterByPath(
|
||||
allMetas: Map<string, FileMeta>,
|
||||
targetPath: string,
|
||||
): Map<string, FileMeta> {
|
||||
const filtered = new Map<string, FileMeta>()
|
||||
|
||||
for (const [filePath, meta] of allMetas) {
|
||||
if (filePath === targetPath || filePath.startsWith(`${targetPath}/`)) {
|
||||
filtered.set(filePath, meta)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate summary statistics for complexity entries.
|
||||
*/
|
||||
private calculateSummary(entries: ComplexityEntry[]): {
|
||||
highComplexity: number
|
||||
mediumComplexity: number
|
||||
lowComplexity: number
|
||||
} {
|
||||
let high = 0
|
||||
let medium = 0
|
||||
let low = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const score = entry.metrics.score
|
||||
if (score >= COMPLEXITY_THRESHOLDS.high) {
|
||||
high++
|
||||
} else if (score >= COMPLEXITY_THRESHOLDS.medium) {
|
||||
medium++
|
||||
} else {
|
||||
low++
|
||||
}
|
||||
}
|
||||
|
||||
return { highComplexity: high, mediumComplexity: medium, lowComplexity: low }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average complexity score.
|
||||
*/
|
||||
private calculateAverageScore(entries: ComplexityEntry[]): number {
|
||||
if (entries.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const total = entries.reduce((sum, entry) => sum + entry.metrics.score, 0)
|
||||
return Math.round((total / entries.length) * 100) / 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Single dependency entry with metadata.
|
||||
*/
|
||||
export interface DependencyEntry {
|
||||
/** Relative path to the dependency */
|
||||
path: string
|
||||
/** Whether the file exists in the project */
|
||||
exists: boolean
|
||||
/** Whether it's an entry point */
|
||||
isEntryPoint: boolean
|
||||
/** Whether it's a hub file */
|
||||
isHub: boolean
|
||||
/** File type classification */
|
||||
fileType: "source" | "test" | "config" | "types" | "unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from get_dependencies tool.
|
||||
*/
|
||||
export interface GetDependenciesResult {
|
||||
/** The file being analyzed */
|
||||
file: string
|
||||
/** Total number of dependencies */
|
||||
totalDependencies: number
|
||||
/** List of dependencies with metadata */
|
||||
dependencies: DependencyEntry[]
|
||||
/** File type of the source file */
|
||||
fileType: "source" | "test" | "config" | "types" | "unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting files that a specific file imports.
|
||||
* Returns the list of internal dependencies from FileMeta.
|
||||
*/
|
||||
export class GetDependenciesTool implements ITool {
|
||||
readonly name = "get_dependencies"
|
||||
readonly description =
|
||||
"Get files that a specific file imports. " +
|
||||
"Returns internal dependencies resolved to file paths."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path to analyze (relative to project root or absolute)",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "analysis" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||
return "Parameter 'path' is required and must be a non-empty string"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const inputPath = (params.path as string).trim()
|
||||
|
||||
try {
|
||||
const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot)
|
||||
|
||||
const meta = await ctx.storage.getMeta(relativePath)
|
||||
if (!meta) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`File not found or not indexed: ${relativePath}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const dependencies: DependencyEntry[] = []
|
||||
for (const depPath of meta.dependencies) {
|
||||
const depMeta = await ctx.storage.getMeta(depPath)
|
||||
dependencies.push({
|
||||
path: depPath,
|
||||
exists: depMeta !== null,
|
||||
isEntryPoint: depMeta?.isEntryPoint ?? false,
|
||||
isHub: depMeta?.isHub ?? false,
|
||||
fileType: depMeta?.fileType ?? "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
dependencies.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
const result: GetDependenciesResult = {
|
||||
file: relativePath,
|
||||
totalDependencies: dependencies.length,
|
||||
dependencies,
|
||||
fileType: meta.fileType,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize input path to relative path from project root.
|
||||
*/
|
||||
private normalizePathToRelative(inputPath: string, projectRoot: string): string {
|
||||
if (path.isAbsolute(inputPath)) {
|
||||
return path.relative(projectRoot, inputPath)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Single dependent entry with metadata.
|
||||
*/
|
||||
export interface DependentEntry {
|
||||
/** Relative path to the dependent file */
|
||||
path: string
|
||||
/** Whether the file is an entry point */
|
||||
isEntryPoint: boolean
|
||||
/** Whether the file is a hub */
|
||||
isHub: boolean
|
||||
/** File type classification */
|
||||
fileType: "source" | "test" | "config" | "types" | "unknown"
|
||||
/** Complexity score of the dependent */
|
||||
complexityScore: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from get_dependents tool.
|
||||
*/
|
||||
export interface GetDependentsResult {
|
||||
/** The file being analyzed */
|
||||
file: string
|
||||
/** Total number of dependents */
|
||||
totalDependents: number
|
||||
/** Whether this file is a hub (>5 dependents) */
|
||||
isHub: boolean
|
||||
/** List of files that import this file */
|
||||
dependents: DependentEntry[]
|
||||
/** File type of the source file */
|
||||
fileType: "source" | "test" | "config" | "types" | "unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting files that import a specific file.
|
||||
* Returns the list of files that depend on the target file.
|
||||
*/
|
||||
export class GetDependentsTool implements ITool {
|
||||
readonly name = "get_dependents"
|
||||
readonly description =
|
||||
"Get files that import a specific file. " +
|
||||
"Returns list of files that depend on the target."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path to analyze (relative to project root or absolute)",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "analysis" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||
return "Parameter 'path' is required and must be a non-empty string"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const inputPath = (params.path as string).trim()
|
||||
|
||||
try {
|
||||
const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot)
|
||||
|
||||
const meta = await ctx.storage.getMeta(relativePath)
|
||||
if (!meta) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`File not found or not indexed: ${relativePath}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const dependents: DependentEntry[] = []
|
||||
for (const depPath of meta.dependents) {
|
||||
const depMeta = await ctx.storage.getMeta(depPath)
|
||||
dependents.push({
|
||||
path: depPath,
|
||||
isEntryPoint: depMeta?.isEntryPoint ?? false,
|
||||
isHub: depMeta?.isHub ?? false,
|
||||
fileType: depMeta?.fileType ?? "unknown",
|
||||
complexityScore: depMeta?.complexity.score ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
dependents.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
const result: GetDependentsResult = {
|
||||
file: relativePath,
|
||||
totalDependents: dependents.length,
|
||||
isHub: meta.isHub,
|
||||
dependents,
|
||||
fileType: meta.fileType,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize input path to relative path from project root.
|
||||
*/
|
||||
private normalizePathToRelative(inputPath: string, projectRoot: string): string {
|
||||
if (path.isAbsolute(inputPath)) {
|
||||
return path.relative(projectRoot, inputPath)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import type { FileData } from "../../../domain/value-objects/FileData.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Types of TODO markers to search for.
|
||||
*/
|
||||
export type TodoType = "TODO" | "FIXME" | "HACK" | "XXX" | "BUG" | "NOTE"
|
||||
|
||||
/**
|
||||
* A single TODO entry found in the codebase.
|
||||
*/
|
||||
export interface TodoEntry {
|
||||
/** Relative path to the file */
|
||||
path: string
|
||||
/** Line number where the TODO is found */
|
||||
line: number
|
||||
/** Type of TODO marker (TODO, FIXME, etc.) */
|
||||
type: TodoType
|
||||
/** The TODO text content */
|
||||
text: string
|
||||
/** Full line content for context */
|
||||
context: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from get_todos tool.
|
||||
*/
|
||||
export interface GetTodosResult {
|
||||
/** The path that was searched (file or directory) */
|
||||
searchedPath: string | null
|
||||
/** Total number of TODOs found */
|
||||
totalTodos: number
|
||||
/** Number of files with TODOs */
|
||||
filesWithTodos: number
|
||||
/** TODOs grouped by type */
|
||||
byType: Record<TodoType, number>
|
||||
/** List of TODO entries */
|
||||
todos: TodoEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported TODO marker patterns.
|
||||
*/
|
||||
const TODO_MARKERS: TodoType[] = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"]
|
||||
|
||||
/**
|
||||
* Regex pattern for matching TODO markers in comments.
|
||||
*/
|
||||
const TODO_PATTERN = new RegExp(
|
||||
`(?://|/\\*|\\*|#)\\s*(${TODO_MARKERS.join("|")})(?:\\([^)]*\\))?:?\\s*(.*)`,
|
||||
"i",
|
||||
)
|
||||
|
||||
/**
|
||||
* Tool for finding TODO/FIXME/HACK comments in the codebase.
|
||||
* Searches through indexed files for common task markers.
|
||||
*/
|
||||
export class GetTodosTool implements ITool {
|
||||
readonly name = "get_todos"
|
||||
readonly description =
|
||||
"Find TODO, FIXME, HACK, XXX, BUG, and NOTE comments in the codebase. " +
|
||||
"Returns list of locations with context."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File or directory to search (optional, defaults to entire project)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "string",
|
||||
description:
|
||||
"Filter by TODO type: TODO, FIXME, HACK, XXX, BUG, NOTE (optional, defaults to all)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "analysis" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
if (params.type !== undefined) {
|
||||
if (typeof params.type !== "string") {
|
||||
return "Parameter 'type' must be a string"
|
||||
}
|
||||
const upperType = params.type.toUpperCase()
|
||||
if (!TODO_MARKERS.includes(upperType as TodoType)) {
|
||||
return `Parameter 'type' must be one of: ${TODO_MARKERS.join(", ")}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const inputPath = params.path as string | undefined
|
||||
const filterType = params.type ? ((params.type as string).toUpperCase() as TodoType) : null
|
||||
|
||||
try {
|
||||
const allFiles = await ctx.storage.getAllFiles()
|
||||
|
||||
if (allFiles.size === 0) {
|
||||
return createSuccessResult(
|
||||
callId,
|
||||
this.createEmptyResult(inputPath ?? null),
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
let filesToSearch = allFiles
|
||||
let searchedPath: string | null = null
|
||||
|
||||
if (inputPath) {
|
||||
const relativePath = this.normalizePathToRelative(inputPath, ctx.projectRoot)
|
||||
searchedPath = relativePath
|
||||
filesToSearch = this.filterByPath(allFiles, relativePath)
|
||||
|
||||
if (filesToSearch.size === 0) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`No files found at path: ${relativePath}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const todos: TodoEntry[] = []
|
||||
const filesWithTodos = new Set<string>()
|
||||
|
||||
for (const [filePath, fileData] of filesToSearch) {
|
||||
const fileTodos = this.findTodosInFile(filePath, fileData.lines, filterType)
|
||||
if (fileTodos.length > 0) {
|
||||
filesWithTodos.add(filePath)
|
||||
todos.push(...fileTodos)
|
||||
}
|
||||
}
|
||||
|
||||
todos.sort((a, b) => {
|
||||
const pathCompare = a.path.localeCompare(b.path)
|
||||
if (pathCompare !== 0) {
|
||||
return pathCompare
|
||||
}
|
||||
return a.line - b.line
|
||||
})
|
||||
|
||||
const byType = this.countByType(todos)
|
||||
|
||||
const result: GetTodosResult = {
|
||||
searchedPath,
|
||||
totalTodos: todos.length,
|
||||
filesWithTodos: filesWithTodos.size,
|
||||
byType,
|
||||
todos,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize input path to relative path from project root.
|
||||
*/
|
||||
private normalizePathToRelative(inputPath: string, projectRoot: string): string {
|
||||
if (path.isAbsolute(inputPath)) {
|
||||
return path.relative(projectRoot, inputPath)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter files by path prefix.
|
||||
*/
|
||||
private filterByPath(
|
||||
allFiles: Map<string, FileData>,
|
||||
targetPath: string,
|
||||
): Map<string, FileData> {
|
||||
const filtered = new Map<string, FileData>()
|
||||
|
||||
for (const [filePath, fileData] of allFiles) {
|
||||
if (filePath === targetPath || filePath.startsWith(`${targetPath}/`)) {
|
||||
filtered.set(filePath, fileData)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all TODOs in a file.
|
||||
*/
|
||||
private findTodosInFile(
|
||||
filePath: string,
|
||||
lines: string[],
|
||||
filterType: TodoType | null,
|
||||
): TodoEntry[] {
|
||||
const todos: TodoEntry[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const match = TODO_PATTERN.exec(line)
|
||||
|
||||
if (match) {
|
||||
const type = match[1].toUpperCase() as TodoType
|
||||
const text = match[2].trim()
|
||||
|
||||
if (filterType && type !== filterType) {
|
||||
continue
|
||||
}
|
||||
|
||||
todos.push({
|
||||
path: filePath,
|
||||
line: i + 1,
|
||||
type,
|
||||
text: text || "(no description)",
|
||||
context: line.trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return todos
|
||||
}
|
||||
|
||||
/**
|
||||
* Count TODOs by type.
|
||||
*/
|
||||
private countByType(todos: TodoEntry[]): Record<TodoType, number> {
|
||||
const counts: Record<TodoType, number> = {
|
||||
TODO: 0,
|
||||
FIXME: 0,
|
||||
HACK: 0,
|
||||
XXX: 0,
|
||||
BUG: 0,
|
||||
NOTE: 0,
|
||||
}
|
||||
|
||||
for (const todo of todos) {
|
||||
counts[todo.type]++
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty result structure.
|
||||
*/
|
||||
private createEmptyResult(searchedPath: string | null): GetTodosResult {
|
||||
return {
|
||||
searchedPath,
|
||||
totalTodos: 0,
|
||||
filesWithTodos: 0,
|
||||
byType: {
|
||||
TODO: 0,
|
||||
FIXME: 0,
|
||||
HACK: 0,
|
||||
XXX: 0,
|
||||
BUG: 0,
|
||||
NOTE: 0,
|
||||
},
|
||||
todos: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/ipuaro/src/infrastructure/tools/analysis/index.ts
Normal file
20
packages/ipuaro/src/infrastructure/tools/analysis/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Analysis tools module exports
|
||||
export {
|
||||
GetDependenciesTool,
|
||||
type GetDependenciesResult,
|
||||
type DependencyEntry,
|
||||
} from "./GetDependenciesTool.js"
|
||||
|
||||
export {
|
||||
GetDependentsTool,
|
||||
type GetDependentsResult,
|
||||
type DependentEntry,
|
||||
} from "./GetDependentsTool.js"
|
||||
|
||||
export {
|
||||
GetComplexityTool,
|
||||
type GetComplexityResult,
|
||||
type ComplexityEntry,
|
||||
} from "./GetComplexityTool.js"
|
||||
|
||||
export { GetTodosTool, type GetTodosResult, type TodoEntry, type TodoType } from "./GetTodosTool.js"
|
||||
140
packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts
Normal file
140
packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { hashLines } from "../../../shared/utils/hash.js"
|
||||
|
||||
/**
|
||||
* Result data from create_file tool.
|
||||
*/
|
||||
export interface CreateFileResult {
|
||||
path: string
|
||||
lines: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for creating new files.
|
||||
* Creates a new file with the specified content.
|
||||
* Requires user confirmation before creating.
|
||||
*/
|
||||
export class CreateFileTool implements ITool {
|
||||
readonly name = "create_file"
|
||||
readonly description =
|
||||
"Create a new file with the specified content. " +
|
||||
"The file path must be within the project root. " +
|
||||
"Requires confirmation before creating."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "string",
|
||||
description: "File content",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = true
|
||||
readonly category = "edit" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||
return "Parameter 'path' is required and must be a non-empty string"
|
||||
}
|
||||
|
||||
if (typeof params.content !== "string") {
|
||||
return "Parameter 'content' is required and must be a string"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const content = params.content as string
|
||||
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await this.fileExists(absolutePath)
|
||||
if (exists) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`File already exists: ${relativePath}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const lines = content.split("\n")
|
||||
|
||||
const confirmed = await ctx.requestConfirmation(
|
||||
`Create new file: ${relativePath} (${String(lines.length)} lines)`,
|
||||
{
|
||||
filePath: relativePath,
|
||||
oldLines: [],
|
||||
newLines: lines,
|
||||
startLine: 1,
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"File creation cancelled by user",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const dirPath = path.dirname(absolutePath)
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
await fs.writeFile(absolutePath, content, "utf-8")
|
||||
|
||||
const stats = await fs.stat(absolutePath)
|
||||
const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs)
|
||||
await ctx.storage.setFile(relativePath, fileData)
|
||||
|
||||
const result: CreateFileResult = {
|
||||
path: relativePath,
|
||||
lines: lines.length,
|
||||
size: stats.size,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists.
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
136
packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts
Normal file
136
packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Result data from delete_file tool.
|
||||
*/
|
||||
export interface DeleteFileResult {
|
||||
path: string
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for deleting files.
|
||||
* Deletes a file from the filesystem and storage.
|
||||
* Requires user confirmation before deleting.
|
||||
*/
|
||||
export class DeleteFileTool implements ITool {
|
||||
readonly name = "delete_file"
|
||||
readonly description =
|
||||
"Delete a file from the project. " +
|
||||
"The file path must be within the project root. " +
|
||||
"Requires confirmation before deleting."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = true
|
||||
readonly category = "edit" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||
return "Parameter 'path' is required and must be a non-empty string"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await this.fileExists(absolutePath)
|
||||
if (!exists) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`File not found: ${relativePath}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const fileContent = await this.getFileContent(absolutePath, relativePath, ctx)
|
||||
|
||||
const confirmed = await ctx.requestConfirmation(`Delete file: ${relativePath}`, {
|
||||
filePath: relativePath,
|
||||
oldLines: fileContent,
|
||||
newLines: [],
|
||||
startLine: 1,
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"File deletion cancelled by user",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
await fs.unlink(absolutePath)
|
||||
|
||||
await ctx.storage.deleteFile(relativePath)
|
||||
await ctx.storage.deleteAST(relativePath)
|
||||
await ctx.storage.deleteMeta(relativePath)
|
||||
|
||||
const result: DeleteFileResult = {
|
||||
path: relativePath,
|
||||
deleted: true,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists.
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
return stats.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content for diff display.
|
||||
*/
|
||||
private async getFileContent(
|
||||
absolutePath: string,
|
||||
relativePath: string,
|
||||
ctx: ToolContext,
|
||||
): Promise<string[]> {
|
||||
const fileData = await ctx.storage.getFile(relativePath)
|
||||
if (fileData) {
|
||||
return fileData.lines
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absolutePath, "utf-8")
|
||||
return content.split("\n")
|
||||
}
|
||||
}
|
||||
226
packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts
Normal file
226
packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { hashLines } from "../../../shared/utils/hash.js"
|
||||
|
||||
/**
|
||||
* Result data from edit_lines tool.
|
||||
*/
|
||||
export interface EditLinesResult {
|
||||
path: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
linesReplaced: number
|
||||
linesInserted: number
|
||||
totalLines: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for editing specific lines in a file.
|
||||
* Replaces lines from start to end with new content.
|
||||
* Requires user confirmation before applying changes.
|
||||
*/
|
||||
export class EditLinesTool implements ITool {
|
||||
readonly name = "edit_lines"
|
||||
readonly description =
|
||||
"Replace lines in a file. Replaces lines from start to end (inclusive) with new content. " +
|
||||
"Requires confirmation before applying changes."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
type: "number",
|
||||
description: "Start line number (1-based, inclusive)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "end",
|
||||
type: "number",
|
||||
description: "End line number (1-based, inclusive)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "string",
|
||||
description: "New content to insert (can be multi-line)",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = true
|
||||
readonly category = "edit" as const
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.path !== "string" || params.path.trim() === "") {
|
||||
return "Parameter 'path' is required and must be a non-empty string"
|
||||
}
|
||||
|
||||
if (typeof params.start !== "number" || !Number.isInteger(params.start)) {
|
||||
return "Parameter 'start' is required and must be an integer"
|
||||
}
|
||||
if (params.start < 1) {
|
||||
return "Parameter 'start' must be >= 1"
|
||||
}
|
||||
|
||||
if (typeof params.end !== "number" || !Number.isInteger(params.end)) {
|
||||
return "Parameter 'end' is required and must be an integer"
|
||||
}
|
||||
if (params.end < 1) {
|
||||
return "Parameter 'end' must be >= 1"
|
||||
}
|
||||
|
||||
if (params.start > params.end) {
|
||||
return "Parameter 'start' must be <= 'end'"
|
||||
}
|
||||
|
||||
if (typeof params.content !== "string") {
|
||||
return "Parameter 'content' is required and must be a string"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const startLine = params.start as number
|
||||
const endLine = params.end as number
|
||||
const newContent = params.content as string
|
||||
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const currentLines = await this.getCurrentLines(absolutePath, relativePath, ctx)
|
||||
const totalLines = currentLines.length
|
||||
|
||||
if (startLine > totalLines) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Start line ${String(startLine)} exceeds file length (${String(totalLines)} lines)`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const adjustedEnd = Math.min(endLine, totalLines)
|
||||
const conflictCheck = await this.checkHashConflict(relativePath, currentLines, ctx)
|
||||
if (conflictCheck) {
|
||||
return createErrorResult(callId, conflictCheck, Date.now() - startTime)
|
||||
}
|
||||
|
||||
const oldLines = currentLines.slice(startLine - 1, adjustedEnd)
|
||||
const newLines = newContent.split("\n")
|
||||
|
||||
const confirmed = await ctx.requestConfirmation(
|
||||
`Replace lines ${String(startLine)}-${String(adjustedEnd)} in ${relativePath}`,
|
||||
{
|
||||
filePath: relativePath,
|
||||
oldLines,
|
||||
newLines,
|
||||
startLine,
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(callId, "Edit cancelled by user", Date.now() - startTime)
|
||||
}
|
||||
|
||||
const updatedLines = [
|
||||
...currentLines.slice(0, startLine - 1),
|
||||
...newLines,
|
||||
...currentLines.slice(adjustedEnd),
|
||||
]
|
||||
|
||||
await this.applyChanges(absolutePath, relativePath, updatedLines, ctx)
|
||||
|
||||
const result: EditLinesResult = {
|
||||
path: relativePath,
|
||||
startLine,
|
||||
endLine: adjustedEnd,
|
||||
linesReplaced: adjustedEnd - startLine + 1,
|
||||
linesInserted: newLines.length,
|
||||
totalLines: updatedLines.length,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current file lines from storage or filesystem.
|
||||
*/
|
||||
private async getCurrentLines(
|
||||
absolutePath: string,
|
||||
relativePath: string,
|
||||
ctx: ToolContext,
|
||||
): Promise<string[]> {
|
||||
const fileData = await ctx.storage.getFile(relativePath)
|
||||
if (fileData) {
|
||||
return fileData.lines
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absolutePath, "utf-8")
|
||||
return content.split("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file has changed since it was indexed.
|
||||
* Returns error message if conflict detected, null otherwise.
|
||||
*/
|
||||
private async checkHashConflict(
|
||||
relativePath: string,
|
||||
currentLines: string[],
|
||||
ctx: ToolContext,
|
||||
): Promise<string | null> {
|
||||
const storedFile = await ctx.storage.getFile(relativePath)
|
||||
if (!storedFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentHash = hashLines(currentLines)
|
||||
if (storedFile.hash !== currentHash) {
|
||||
return "File has been modified externally. Please refresh the file before editing."
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply changes to filesystem and storage.
|
||||
*/
|
||||
private async applyChanges(
|
||||
absolutePath: string,
|
||||
relativePath: string,
|
||||
lines: string[],
|
||||
ctx: ToolContext,
|
||||
): Promise<void> {
|
||||
const content = lines.join("\n")
|
||||
await fs.writeFile(absolutePath, content, "utf-8")
|
||||
|
||||
const stats = await fs.stat(absolutePath)
|
||||
const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs)
|
||||
await ctx.storage.setFile(relativePath, fileData)
|
||||
}
|
||||
}
|
||||
4
packages/ipuaro/src/infrastructure/tools/edit/index.ts
Normal file
4
packages/ipuaro/src/infrastructure/tools/edit/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Edit tools exports
|
||||
export { EditLinesTool, type EditLinesResult } from "./EditLinesTool.js"
|
||||
export { CreateFileTool, type CreateFileResult } from "./CreateFileTool.js"
|
||||
export { DeleteFileTool, type DeleteFileResult } from "./DeleteFileTool.js"
|
||||
155
packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts
Normal file
155
packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { type CommitResult, type SimpleGit, simpleGit } from "simple-git"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Author information.
|
||||
*/
|
||||
export interface CommitAuthor {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from git_commit tool.
|
||||
*/
|
||||
export interface GitCommitResult {
|
||||
/** Commit hash */
|
||||
hash: string
|
||||
/** Current branch */
|
||||
branch: string
|
||||
/** Commit message */
|
||||
message: string
|
||||
/** Number of files changed */
|
||||
filesChanged: number
|
||||
/** Number of insertions */
|
||||
insertions: number
|
||||
/** Number of deletions */
|
||||
deletions: number
|
||||
/** Author information */
|
||||
author: CommitAuthor | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for creating git commits.
|
||||
* Requires confirmation before execution.
|
||||
*/
|
||||
export class GitCommitTool implements ITool {
|
||||
readonly name = "git_commit"
|
||||
readonly description =
|
||||
"Create a git commit with the specified message. " +
|
||||
"Will ask for confirmation. Optionally stage specific files first."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "message",
|
||||
type: "string",
|
||||
description: "Commit message",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "files",
|
||||
type: "array",
|
||||
description: "Files to stage before commit (optional, defaults to all staged)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = true
|
||||
readonly category = "git" as const
|
||||
|
||||
private readonly gitFactory: (basePath: string) => SimpleGit
|
||||
|
||||
constructor(gitFactory?: (basePath: string) => SimpleGit) {
|
||||
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.message === undefined) {
|
||||
return "Parameter 'message' is required"
|
||||
}
|
||||
if (typeof params.message !== "string") {
|
||||
return "Parameter 'message' must be a string"
|
||||
}
|
||||
if (params.message.trim() === "") {
|
||||
return "Parameter 'message' cannot be empty"
|
||||
}
|
||||
if (params.files !== undefined) {
|
||||
if (!Array.isArray(params.files)) {
|
||||
return "Parameter 'files' must be an array"
|
||||
}
|
||||
for (const file of params.files) {
|
||||
if (typeof file !== "string") {
|
||||
return "Parameter 'files' must be an array of strings"
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const message = params.message as string
|
||||
const files = params.files as string[] | undefined
|
||||
|
||||
try {
|
||||
const git = this.gitFactory(ctx.projectRoot)
|
||||
|
||||
const isRepo = await git.checkIsRepo()
|
||||
if (!isRepo) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Not a git repository. Initialize with 'git init' first.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
await git.add(files)
|
||||
}
|
||||
|
||||
const status = await git.status()
|
||||
if (status.staged.length === 0 && (!files || files.length === 0)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Nothing to commit. Stage files first with 'git add' or provide 'files' parameter.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const commitSummary = `Committing ${String(status.staged.length)} file(s): ${message}`
|
||||
const confirmed = await ctx.requestConfirmation(commitSummary)
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(callId, "Commit cancelled by user", Date.now() - startTime)
|
||||
}
|
||||
|
||||
const commitResult = await git.commit(message)
|
||||
const result = this.formatCommitResult(commitResult, message)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format simple-git CommitResult into our result structure.
|
||||
*/
|
||||
private formatCommitResult(commit: CommitResult, message: string): GitCommitResult {
|
||||
return {
|
||||
hash: commit.commit,
|
||||
branch: commit.branch,
|
||||
message,
|
||||
filesChanged: commit.summary.changes,
|
||||
insertions: commit.summary.insertions,
|
||||
deletions: commit.summary.deletions,
|
||||
author: commit.author ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
155
packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts
Normal file
155
packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { simpleGit, type SimpleGit } from "simple-git"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* A single file diff entry.
|
||||
*/
|
||||
export interface DiffEntry {
|
||||
/** File path */
|
||||
file: string
|
||||
/** Number of insertions */
|
||||
insertions: number
|
||||
/** Number of deletions */
|
||||
deletions: number
|
||||
/** Whether the file is binary */
|
||||
binary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from git_diff tool.
|
||||
*/
|
||||
export interface GitDiffResult {
|
||||
/** Whether showing staged or all changes */
|
||||
staged: boolean
|
||||
/** Path filter applied (null if all files) */
|
||||
pathFilter: string | null
|
||||
/** Whether there are any changes */
|
||||
hasChanges: boolean
|
||||
/** Summary of changes */
|
||||
summary: {
|
||||
/** Number of files changed */
|
||||
filesChanged: number
|
||||
/** Total insertions */
|
||||
insertions: number
|
||||
/** Total deletions */
|
||||
deletions: number
|
||||
}
|
||||
/** List of changed files */
|
||||
files: DiffEntry[]
|
||||
/** Full diff text */
|
||||
diff: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting uncommitted git changes (diff).
|
||||
* Shows what has changed but not yet committed.
|
||||
*/
|
||||
export class GitDiffTool implements ITool {
|
||||
readonly name = "git_diff"
|
||||
readonly description =
|
||||
"Get uncommitted changes (diff). " + "Shows what has changed but not yet committed."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Limit diff to specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "staged",
|
||||
type: "boolean",
|
||||
description: "Show only staged changes (default: false, shows all)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "git" as const
|
||||
|
||||
private readonly gitFactory: (basePath: string) => SimpleGit
|
||||
|
||||
constructor(gitFactory?: (basePath: string) => SimpleGit) {
|
||||
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
if (params.staged !== undefined && typeof params.staged !== "boolean") {
|
||||
return "Parameter 'staged' must be a boolean"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const pathFilter = (params.path as string) ?? null
|
||||
const staged = (params.staged as boolean) ?? false
|
||||
|
||||
try {
|
||||
const git = this.gitFactory(ctx.projectRoot)
|
||||
|
||||
const isRepo = await git.checkIsRepo()
|
||||
if (!isRepo) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Not a git repository. Initialize with 'git init' first.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const diffArgs = this.buildDiffArgs(staged, pathFilter)
|
||||
const diffSummary = await git.diffSummary(diffArgs)
|
||||
const diffText = await git.diff(diffArgs)
|
||||
|
||||
const files: DiffEntry[] = diffSummary.files.map((f) => ({
|
||||
file: f.file,
|
||||
insertions: "insertions" in f ? f.insertions : 0,
|
||||
deletions: "deletions" in f ? f.deletions : 0,
|
||||
binary: f.binary,
|
||||
}))
|
||||
|
||||
const result: GitDiffResult = {
|
||||
staged,
|
||||
pathFilter,
|
||||
hasChanges: diffSummary.files.length > 0,
|
||||
summary: {
|
||||
filesChanged: diffSummary.files.length,
|
||||
insertions: diffSummary.insertions,
|
||||
deletions: diffSummary.deletions,
|
||||
},
|
||||
files,
|
||||
diff: diffText,
|
||||
}
|
||||
|
||||
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 diff arguments array.
|
||||
*/
|
||||
private buildDiffArgs(staged: boolean, pathFilter: string | null): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
if (staged) {
|
||||
args.push("--cached")
|
||||
}
|
||||
|
||||
if (pathFilter) {
|
||||
args.push("--", pathFilter)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
}
|
||||
129
packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts
Normal file
129
packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { simpleGit, type SimpleGit, type StatusResult } from "simple-git"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* File status entry in git status.
|
||||
*/
|
||||
export interface FileStatusEntry {
|
||||
/** Relative file path */
|
||||
path: string
|
||||
/** Working directory status (modified, deleted, etc.) */
|
||||
workingDir: string
|
||||
/** Index/staging status */
|
||||
index: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from git_status tool.
|
||||
*/
|
||||
export interface GitStatusResult {
|
||||
/** Current branch name */
|
||||
branch: string
|
||||
/** Tracking branch (e.g., origin/main) */
|
||||
tracking: string | null
|
||||
/** Number of commits ahead of tracking */
|
||||
ahead: number
|
||||
/** Number of commits behind tracking */
|
||||
behind: number
|
||||
/** Files staged for commit */
|
||||
staged: FileStatusEntry[]
|
||||
/** Modified files not staged */
|
||||
modified: FileStatusEntry[]
|
||||
/** Untracked files */
|
||||
untracked: string[]
|
||||
/** Files with merge conflicts */
|
||||
conflicted: string[]
|
||||
/** Whether working directory is clean */
|
||||
isClean: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting git repository status.
|
||||
* Returns branch info, staged/modified/untracked files.
|
||||
*/
|
||||
export class GitStatusTool implements ITool {
|
||||
readonly name = "git_status"
|
||||
readonly description =
|
||||
"Get current git repository status. " +
|
||||
"Returns branch name, staged files, modified files, and untracked files."
|
||||
readonly parameters: ToolParameterSchema[] = []
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "git" as const
|
||||
|
||||
private readonly gitFactory: (basePath: string) => SimpleGit
|
||||
|
||||
constructor(gitFactory?: (basePath: string) => SimpleGit) {
|
||||
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
|
||||
}
|
||||
|
||||
validateParams(_params: Record<string, unknown>): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
try {
|
||||
const git = this.gitFactory(ctx.projectRoot)
|
||||
|
||||
const isRepo = await git.checkIsRepo()
|
||||
if (!isRepo) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Not a git repository. Initialize with 'git init' first.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const status = await git.status()
|
||||
const result = this.formatStatus(status)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format simple-git StatusResult into our result structure.
|
||||
*/
|
||||
private formatStatus(status: StatusResult): GitStatusResult {
|
||||
const staged: FileStatusEntry[] = []
|
||||
const modified: FileStatusEntry[] = []
|
||||
|
||||
for (const file of status.files) {
|
||||
const entry: FileStatusEntry = {
|
||||
path: file.path,
|
||||
workingDir: file.working_dir,
|
||||
index: file.index,
|
||||
}
|
||||
|
||||
if (file.index !== " " && file.index !== "?") {
|
||||
staged.push(entry)
|
||||
}
|
||||
|
||||
if (file.working_dir !== " " && file.working_dir !== "?") {
|
||||
modified.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branch: status.current ?? "HEAD (detached)",
|
||||
tracking: status.tracking ?? null,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
staged,
|
||||
modified,
|
||||
untracked: status.not_added,
|
||||
conflicted: status.conflicted,
|
||||
isClean: status.isClean(),
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/ipuaro/src/infrastructure/tools/git/index.ts
Normal file
6
packages/ipuaro/src/infrastructure/tools/git/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Git tools exports
|
||||
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./GitStatusTool.js"
|
||||
|
||||
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./GitDiffTool.js"
|
||||
|
||||
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./GitCommitTool.js"
|
||||
@@ -10,3 +10,66 @@ export {
|
||||
type GetStructureResult,
|
||||
type TreeNode,
|
||||
} from "./read/GetStructureTool.js"
|
||||
|
||||
// Edit tools
|
||||
export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js"
|
||||
export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js"
|
||||
export { DeleteFileTool, type DeleteFileResult } from "./edit/DeleteFileTool.js"
|
||||
|
||||
// Search tools
|
||||
export {
|
||||
FindReferencesTool,
|
||||
type FindReferencesResult,
|
||||
type SymbolReference,
|
||||
} from "./search/FindReferencesTool.js"
|
||||
export {
|
||||
FindDefinitionTool,
|
||||
type FindDefinitionResult,
|
||||
type DefinitionLocation,
|
||||
} from "./search/FindDefinitionTool.js"
|
||||
|
||||
// Analysis tools
|
||||
export {
|
||||
GetDependenciesTool,
|
||||
type GetDependenciesResult,
|
||||
type DependencyEntry,
|
||||
} from "./analysis/GetDependenciesTool.js"
|
||||
|
||||
export {
|
||||
GetDependentsTool,
|
||||
type GetDependentsResult,
|
||||
type DependentEntry,
|
||||
} from "./analysis/GetDependentsTool.js"
|
||||
|
||||
export {
|
||||
GetComplexityTool,
|
||||
type GetComplexityResult,
|
||||
type ComplexityEntry,
|
||||
} from "./analysis/GetComplexityTool.js"
|
||||
|
||||
export {
|
||||
GetTodosTool,
|
||||
type GetTodosResult,
|
||||
type TodoEntry,
|
||||
type TodoType,
|
||||
} from "./analysis/GetTodosTool.js"
|
||||
|
||||
// Git tools
|
||||
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./git/GitStatusTool.js"
|
||||
|
||||
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./git/GitDiffTool.js"
|
||||
|
||||
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./git/GitCommitTool.js"
|
||||
|
||||
// Run tools
|
||||
export {
|
||||
CommandSecurity,
|
||||
DEFAULT_BLACKLIST,
|
||||
DEFAULT_WHITELIST,
|
||||
type CommandClassification,
|
||||
type SecurityCheckResult,
|
||||
} from "./run/CommandSecurity.js"
|
||||
|
||||
export { RunCommandTool, type RunCommandResult } from "./run/RunCommandTool.js"
|
||||
|
||||
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./run/RunTestsTool.js"
|
||||
|
||||
257
packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts
Normal file
257
packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Command security classification.
|
||||
*/
|
||||
export type CommandClassification = "allowed" | "blocked" | "requires_confirmation"
|
||||
|
||||
/**
|
||||
* Result of command security check.
|
||||
*/
|
||||
export interface SecurityCheckResult {
|
||||
/** Classification of the command */
|
||||
classification: CommandClassification
|
||||
/** Reason for the classification */
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous commands that are always blocked.
|
||||
* These commands can cause data loss or security issues.
|
||||
*/
|
||||
export const DEFAULT_BLACKLIST: string[] = [
|
||||
// Destructive file operations
|
||||
"rm -rf",
|
||||
"rm -r",
|
||||
"rm -fr",
|
||||
"rmdir",
|
||||
// Dangerous git operations
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"git reset --hard",
|
||||
"git clean -fd",
|
||||
"git clean -f",
|
||||
// Publishing/deployment
|
||||
"npm publish",
|
||||
"yarn publish",
|
||||
"pnpm publish",
|
||||
// System commands
|
||||
"sudo",
|
||||
"su ",
|
||||
"chmod",
|
||||
"chown",
|
||||
// Network/download commands that could be dangerous
|
||||
"| sh",
|
||||
"| bash",
|
||||
// Environment manipulation
|
||||
"export ",
|
||||
"unset ",
|
||||
// Process control
|
||||
"kill -9",
|
||||
"killall",
|
||||
"pkill",
|
||||
// Disk operations (require exact command start)
|
||||
"mkfs",
|
||||
"fdisk",
|
||||
// Other dangerous
|
||||
":(){ :|:& };:",
|
||||
"eval ",
|
||||
]
|
||||
|
||||
/**
|
||||
* Safe commands that don't require confirmation.
|
||||
* Matched by first word (command name).
|
||||
*/
|
||||
export const DEFAULT_WHITELIST: string[] = [
|
||||
// Package managers
|
||||
"npm",
|
||||
"pnpm",
|
||||
"yarn",
|
||||
"npx",
|
||||
"bun",
|
||||
// Node.js
|
||||
"node",
|
||||
"tsx",
|
||||
"ts-node",
|
||||
// Git (read operations)
|
||||
"git",
|
||||
// Build tools
|
||||
"tsc",
|
||||
"tsup",
|
||||
"esbuild",
|
||||
"vite",
|
||||
"webpack",
|
||||
"rollup",
|
||||
// Testing
|
||||
"vitest",
|
||||
"jest",
|
||||
"mocha",
|
||||
"playwright",
|
||||
"cypress",
|
||||
// Linting/formatting
|
||||
"eslint",
|
||||
"prettier",
|
||||
"biome",
|
||||
// Utilities
|
||||
"echo",
|
||||
"cat",
|
||||
"ls",
|
||||
"pwd",
|
||||
"which",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"find",
|
||||
"wc",
|
||||
"sort",
|
||||
"diff",
|
||||
]
|
||||
|
||||
/**
|
||||
* Git subcommands that are safe and don't need confirmation.
|
||||
*/
|
||||
const SAFE_GIT_SUBCOMMANDS: string[] = [
|
||||
"status",
|
||||
"log",
|
||||
"diff",
|
||||
"show",
|
||||
"branch",
|
||||
"remote",
|
||||
"fetch",
|
||||
"pull",
|
||||
"stash",
|
||||
"tag",
|
||||
"blame",
|
||||
"ls-files",
|
||||
"ls-tree",
|
||||
"rev-parse",
|
||||
"describe",
|
||||
]
|
||||
|
||||
/**
|
||||
* Command security checker.
|
||||
* Determines if a command is safe to execute, blocked, or requires confirmation.
|
||||
*/
|
||||
export class CommandSecurity {
|
||||
private readonly blacklist: string[]
|
||||
private readonly whitelist: string[]
|
||||
|
||||
constructor(blacklist: string[] = DEFAULT_BLACKLIST, whitelist: string[] = DEFAULT_WHITELIST) {
|
||||
this.blacklist = blacklist.map((cmd) => cmd.toLowerCase())
|
||||
this.whitelist = whitelist.map((cmd) => cmd.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is safe to execute.
|
||||
*/
|
||||
check(command: string): SecurityCheckResult {
|
||||
const normalized = command.trim().toLowerCase()
|
||||
|
||||
const blacklistMatch = this.isBlacklisted(normalized)
|
||||
if (blacklistMatch) {
|
||||
return {
|
||||
classification: "blocked",
|
||||
reason: `Command contains blocked pattern: '${blacklistMatch}'`,
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isWhitelisted(normalized)) {
|
||||
return {
|
||||
classification: "allowed",
|
||||
reason: "Command is in the whitelist",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
classification: "requires_confirmation",
|
||||
reason: "Command is not in the whitelist and requires user confirmation",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command matches any blacklist pattern.
|
||||
* Returns the matched pattern or null.
|
||||
*/
|
||||
private isBlacklisted(command: string): string | null {
|
||||
for (const pattern of this.blacklist) {
|
||||
if (command.includes(pattern)) {
|
||||
return pattern
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command's first word is in the whitelist.
|
||||
*/
|
||||
private isWhitelisted(command: string): boolean {
|
||||
const firstWord = this.getFirstWord(command)
|
||||
|
||||
if (!this.whitelist.includes(firstWord)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (firstWord === "git") {
|
||||
return this.isGitCommandSafe(command)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if git command is safe (read-only operations).
|
||||
*/
|
||||
private isGitCommandSafe(command: string): boolean {
|
||||
const parts = command.split(/\s+/)
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subcommand = parts[1]
|
||||
return SAFE_GIT_SUBCOMMANDS.includes(subcommand)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first word from command.
|
||||
*/
|
||||
private getFirstWord(command: string): string {
|
||||
const match = /^(\S+)/.exec(command)
|
||||
return match ? match[1] : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Add patterns to the blacklist.
|
||||
*/
|
||||
addToBlacklist(patterns: string[]): void {
|
||||
for (const pattern of patterns) {
|
||||
const normalized = pattern.toLowerCase()
|
||||
if (!this.blacklist.includes(normalized)) {
|
||||
this.blacklist.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add commands to the whitelist.
|
||||
*/
|
||||
addToWhitelist(commands: string[]): void {
|
||||
for (const cmd of commands) {
|
||||
const normalized = cmd.toLowerCase()
|
||||
if (!this.whitelist.includes(normalized)) {
|
||||
this.whitelist.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current blacklist.
|
||||
*/
|
||||
getBlacklist(): string[] {
|
||||
return [...this.blacklist]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current whitelist.
|
||||
*/
|
||||
getWhitelist(): string[] {
|
||||
return [...this.whitelist]
|
||||
}
|
||||
}
|
||||
227
packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts
Normal file
227
packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { exec } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { CommandSecurity } from "./CommandSecurity.js"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* Result data from run_command tool.
|
||||
*/
|
||||
export interface RunCommandResult {
|
||||
/** The command that was executed */
|
||||
command: string
|
||||
/** Exit code (0 = success) */
|
||||
exitCode: number
|
||||
/** Standard output */
|
||||
stdout: string
|
||||
/** Standard error output */
|
||||
stderr: string
|
||||
/** Whether command was successful (exit code 0) */
|
||||
success: boolean
|
||||
/** Execution time in milliseconds */
|
||||
durationMs: number
|
||||
/** Whether user confirmation was required */
|
||||
requiredConfirmation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default command timeout in milliseconds.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 30000
|
||||
|
||||
/**
|
||||
* Maximum output size in characters.
|
||||
*/
|
||||
const MAX_OUTPUT_SIZE = 100000
|
||||
|
||||
/**
|
||||
* Tool for executing shell commands.
|
||||
* Commands are checked against blacklist/whitelist for security.
|
||||
*/
|
||||
export class RunCommandTool implements ITool {
|
||||
readonly name = "run_command"
|
||||
readonly description =
|
||||
"Execute a shell command in the project directory. " +
|
||||
"Commands are checked against blacklist/whitelist for security. " +
|
||||
"Unknown commands require user confirmation."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "command",
|
||||
type: "string",
|
||||
description: "Shell command to execute",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
type: "number",
|
||||
description: "Timeout in milliseconds (default: 30000)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "run" as const
|
||||
|
||||
private readonly security: CommandSecurity
|
||||
private readonly execFn: typeof execAsync
|
||||
|
||||
constructor(security?: CommandSecurity, execFn?: typeof execAsync) {
|
||||
this.security = security ?? new CommandSecurity()
|
||||
this.execFn = execFn ?? execAsync
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.command === undefined) {
|
||||
return "Parameter 'command' is required"
|
||||
}
|
||||
if (typeof params.command !== "string") {
|
||||
return "Parameter 'command' must be a string"
|
||||
}
|
||||
if (params.command.trim() === "") {
|
||||
return "Parameter 'command' cannot be empty"
|
||||
}
|
||||
if (params.timeout !== undefined) {
|
||||
if (typeof params.timeout !== "number") {
|
||||
return "Parameter 'timeout' must be a number"
|
||||
}
|
||||
if (params.timeout <= 0) {
|
||||
return "Parameter 'timeout' must be positive"
|
||||
}
|
||||
if (params.timeout > 600000) {
|
||||
return "Parameter 'timeout' cannot exceed 600000ms (10 minutes)"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const command = params.command as string
|
||||
const timeout = (params.timeout as number) ?? DEFAULT_TIMEOUT
|
||||
|
||||
const securityCheck = this.security.check(command)
|
||||
|
||||
if (securityCheck.classification === "blocked") {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Command blocked for security: ${securityCheck.reason}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
let requiredConfirmation = false
|
||||
|
||||
if (securityCheck.classification === "requires_confirmation") {
|
||||
requiredConfirmation = true
|
||||
const confirmed = await ctx.requestConfirmation(
|
||||
`Execute command: ${command}\n\nReason: ${securityCheck.reason}`,
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Command execution cancelled by user",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const execStartTime = Date.now()
|
||||
|
||||
const { stdout, stderr } = await this.execFn(command, {
|
||||
cwd: ctx.projectRoot,
|
||||
timeout,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
})
|
||||
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
const result: RunCommandResult = {
|
||||
command,
|
||||
exitCode: 0,
|
||||
stdout: this.truncateOutput(stdout),
|
||||
stderr: this.truncateOutput(stderr),
|
||||
success: true,
|
||||
durationMs,
|
||||
requiredConfirmation,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
return this.handleExecError(callId, command, error, requiredConfirmation, startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle exec errors and return appropriate result.
|
||||
*/
|
||||
private handleExecError(
|
||||
callId: string,
|
||||
command: string,
|
||||
error: unknown,
|
||||
requiredConfirmation: boolean,
|
||||
startTime: number,
|
||||
): ToolResult {
|
||||
if (this.isExecError(error)) {
|
||||
const result: RunCommandResult = {
|
||||
command,
|
||||
exitCode: error.code ?? 1,
|
||||
stdout: this.truncateOutput(error.stdout ?? ""),
|
||||
stderr: this.truncateOutput(error.stderr ?? error.message),
|
||||
success: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
requiredConfirmation,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Command timed out: ${command}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
return createErrorResult(callId, error.message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
return createErrorResult(callId, String(error), Date.now() - startTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for exec error.
|
||||
*/
|
||||
private isExecError(
|
||||
error: unknown,
|
||||
): error is Error & { code?: number; stdout?: string; stderr?: string } {
|
||||
return error instanceof Error && "code" in error
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output if too large.
|
||||
*/
|
||||
private truncateOutput(output: string): string {
|
||||
if (output.length <= MAX_OUTPUT_SIZE) {
|
||||
return output
|
||||
}
|
||||
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the security checker instance.
|
||||
*/
|
||||
getSecurity(): CommandSecurity {
|
||||
return this.security
|
||||
}
|
||||
}
|
||||
353
packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts
Normal file
353
packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { exec } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import * as path from "node:path"
|
||||
import * as fs from "node:fs/promises"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* Supported test runners.
|
||||
*/
|
||||
export type TestRunner = "vitest" | "jest" | "mocha" | "npm"
|
||||
|
||||
/**
|
||||
* Result data from run_tests tool.
|
||||
*/
|
||||
export interface RunTestsResult {
|
||||
/** Test runner that was used */
|
||||
runner: TestRunner
|
||||
/** Command that was executed */
|
||||
command: string
|
||||
/** Whether all tests passed */
|
||||
passed: boolean
|
||||
/** Exit code */
|
||||
exitCode: number
|
||||
/** Standard output */
|
||||
stdout: string
|
||||
/** Standard error output */
|
||||
stderr: string
|
||||
/** Execution time in milliseconds */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Default test timeout in milliseconds (5 minutes).
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 300000
|
||||
|
||||
/**
|
||||
* Maximum output size in characters.
|
||||
*/
|
||||
const MAX_OUTPUT_SIZE = 200000
|
||||
|
||||
/**
|
||||
* Tool for running project tests.
|
||||
* Auto-detects test runner (vitest, jest, mocha, npm test).
|
||||
*/
|
||||
export class RunTestsTool implements ITool {
|
||||
readonly name = "run_tests"
|
||||
readonly description =
|
||||
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
|
||||
"Returns test results summary."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Run tests for specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "filter",
|
||||
type: "string",
|
||||
description: "Filter tests by name pattern",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "watch",
|
||||
type: "boolean",
|
||||
description: "Run in watch mode (default: false)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "run" as const
|
||||
|
||||
private readonly execFn: typeof execAsync
|
||||
private readonly fsAccess: typeof fs.access
|
||||
private readonly fsReadFile: typeof fs.readFile
|
||||
|
||||
constructor(
|
||||
execFn?: typeof execAsync,
|
||||
fsAccess?: typeof fs.access,
|
||||
fsReadFile?: typeof fs.readFile,
|
||||
) {
|
||||
this.execFn = execFn ?? execAsync
|
||||
this.fsAccess = fsAccess ?? fs.access
|
||||
this.fsReadFile = fsReadFile ?? fs.readFile
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
if (params.filter !== undefined && typeof params.filter !== "string") {
|
||||
return "Parameter 'filter' must be a string"
|
||||
}
|
||||
if (params.watch !== undefined && typeof params.watch !== "boolean") {
|
||||
return "Parameter 'watch' must be a boolean"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const testPath = params.path as string | undefined
|
||||
const filter = params.filter as string | undefined
|
||||
const watch = (params.watch as boolean) ?? false
|
||||
|
||||
try {
|
||||
const runner = await this.detectTestRunner(ctx.projectRoot)
|
||||
|
||||
if (!runner) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"No test runner detected. Ensure vitest, jest, or mocha is installed, or 'test' script exists in package.json.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const command = this.buildCommand(runner, testPath, filter, watch)
|
||||
const execStartTime = Date.now()
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await this.execFn(command, {
|
||||
cwd: ctx.projectRoot,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
env: { ...process.env, FORCE_COLOR: "0", CI: "true" },
|
||||
})
|
||||
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
const result: RunTestsResult = {
|
||||
runner,
|
||||
command,
|
||||
passed: true,
|
||||
exitCode: 0,
|
||||
stdout: this.truncateOutput(stdout),
|
||||
stderr: this.truncateOutput(stderr),
|
||||
durationMs,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
return this.handleExecError(
|
||||
callId,
|
||||
runner,
|
||||
command,
|
||||
error,
|
||||
execStartTime,
|
||||
startTime,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which test runner is available in the project.
|
||||
*/
|
||||
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
|
||||
if (await this.hasFile(projectRoot, "vitest.config.ts")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "vitest.config.js")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "vitest.config.mts")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.js")) {
|
||||
return "jest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.ts")) {
|
||||
return "jest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.json")) {
|
||||
return "jest"
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(projectRoot, "package.json")
|
||||
try {
|
||||
const content = await this.fsReadFile(packageJsonPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as {
|
||||
scripts?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
|
||||
return "vitest"
|
||||
}
|
||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
|
||||
return "jest"
|
||||
}
|
||||
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
|
||||
return "mocha"
|
||||
}
|
||||
|
||||
if (pkg.scripts?.test) {
|
||||
return "npm"
|
||||
}
|
||||
} catch {
|
||||
// package.json doesn't exist or is invalid
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the test command based on runner and options.
|
||||
*/
|
||||
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
|
||||
const parts: string[] = []
|
||||
|
||||
switch (runner) {
|
||||
case "vitest":
|
||||
parts.push("npx vitest")
|
||||
if (!watch) {
|
||||
parts.push("run")
|
||||
}
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
break
|
||||
|
||||
case "jest":
|
||||
parts.push("npx jest")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
break
|
||||
|
||||
case "mocha":
|
||||
parts.push("npx mocha")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("--grep", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
break
|
||||
|
||||
case "npm":
|
||||
parts.push("npm test")
|
||||
if (testPath || filter) {
|
||||
parts.push("--")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push(`"${filter}"`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists.
|
||||
*/
|
||||
private async hasFile(projectRoot: string, filename: string): Promise<boolean> {
|
||||
try {
|
||||
await this.fsAccess(path.join(projectRoot, filename))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle exec errors and return appropriate result.
|
||||
*/
|
||||
private handleExecError(
|
||||
callId: string,
|
||||
runner: TestRunner,
|
||||
command: string,
|
||||
error: unknown,
|
||||
execStartTime: number,
|
||||
startTime: number,
|
||||
): ToolResult {
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
if (this.isExecError(error)) {
|
||||
const result: RunTestsResult = {
|
||||
runner,
|
||||
command,
|
||||
passed: false,
|
||||
exitCode: error.code ?? 1,
|
||||
stdout: this.truncateOutput(error.stdout ?? ""),
|
||||
stderr: this.truncateOutput(error.stderr ?? error.message),
|
||||
durationMs,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Tests timed out after ${String(DEFAULT_TIMEOUT / 1000)} seconds`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
return createErrorResult(callId, error.message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
return createErrorResult(callId, String(error), Date.now() - startTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for exec error.
|
||||
*/
|
||||
private isExecError(
|
||||
error: unknown,
|
||||
): error is Error & { code?: number; stdout?: string; stderr?: string } {
|
||||
return error instanceof Error && "code" in error
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output if too large.
|
||||
*/
|
||||
private truncateOutput(output: string): string {
|
||||
if (output.length <= MAX_OUTPUT_SIZE) {
|
||||
return output
|
||||
}
|
||||
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
|
||||
}
|
||||
}
|
||||
12
packages/ipuaro/src/infrastructure/tools/run/index.ts
Normal file
12
packages/ipuaro/src/infrastructure/tools/run/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Run tools exports
|
||||
export {
|
||||
CommandSecurity,
|
||||
DEFAULT_BLACKLIST,
|
||||
DEFAULT_WHITELIST,
|
||||
type CommandClassification,
|
||||
type SecurityCheckResult,
|
||||
} from "./CommandSecurity.js"
|
||||
|
||||
export { RunCommandTool, type RunCommandResult } from "./RunCommandTool.js"
|
||||
|
||||
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./RunTestsTool.js"
|
||||
@@ -0,0 +1,221 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import type { SymbolLocation } from "../../../domain/services/IStorage.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* A single definition location with context.
|
||||
*/
|
||||
export interface DefinitionLocation {
|
||||
path: string
|
||||
line: number
|
||||
type: SymbolLocation["type"]
|
||||
context: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from find_definition tool.
|
||||
*/
|
||||
export interface FindDefinitionResult {
|
||||
symbol: string
|
||||
found: boolean
|
||||
definitions: DefinitionLocation[]
|
||||
suggestions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for finding where a symbol is defined.
|
||||
* Uses the SymbolIndex to locate definitions.
|
||||
*/
|
||||
export class FindDefinitionTool implements ITool {
|
||||
readonly name = "find_definition"
|
||||
readonly description =
|
||||
"Find where a symbol is defined. " + "Returns file path, line number, and symbol type."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "symbol",
|
||||
type: "string",
|
||||
description: "Symbol name to find definition for",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "search" as const
|
||||
|
||||
private readonly contextLines = 2
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.symbol !== "string" || params.symbol.trim() === "") {
|
||||
return "Parameter 'symbol' is required and must be a non-empty string"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const symbol = (params.symbol as string).trim()
|
||||
|
||||
try {
|
||||
const symbolIndex = await ctx.storage.getSymbolIndex()
|
||||
const locations = symbolIndex.get(symbol)
|
||||
|
||||
if (!locations || locations.length === 0) {
|
||||
const suggestions = this.findSimilarSymbols(symbol, symbolIndex)
|
||||
return createSuccessResult(
|
||||
callId,
|
||||
{
|
||||
symbol,
|
||||
found: false,
|
||||
definitions: [],
|
||||
suggestions: suggestions.length > 0 ? suggestions : undefined,
|
||||
} satisfies FindDefinitionResult,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const definitions: DefinitionLocation[] = []
|
||||
for (const loc of locations) {
|
||||
const context = await this.getContext(loc, ctx)
|
||||
definitions.push({
|
||||
path: loc.path,
|
||||
line: loc.line,
|
||||
type: loc.type,
|
||||
context,
|
||||
})
|
||||
}
|
||||
|
||||
definitions.sort((a, b) => {
|
||||
const pathCompare = a.path.localeCompare(b.path)
|
||||
if (pathCompare !== 0) {
|
||||
return pathCompare
|
||||
}
|
||||
return a.line - b.line
|
||||
})
|
||||
|
||||
const result: FindDefinitionResult = {
|
||||
symbol,
|
||||
found: true,
|
||||
definitions,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context lines around the definition.
|
||||
*/
|
||||
private async getContext(loc: SymbolLocation, ctx: ToolContext): Promise<string> {
|
||||
try {
|
||||
const lines = await this.getFileLines(loc.path, ctx)
|
||||
if (lines.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lineIndex = loc.line - 1
|
||||
const startIndex = Math.max(0, lineIndex - this.contextLines)
|
||||
const endIndex = Math.min(lines.length - 1, lineIndex + this.contextLines)
|
||||
|
||||
const contextLines: string[] = []
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const lineNum = i + 1
|
||||
const prefix = i === lineIndex ? ">" : " "
|
||||
contextLines.push(`${prefix}${String(lineNum).padStart(4)}│${lines[i]}`)
|
||||
}
|
||||
|
||||
return contextLines.join("\n")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file lines from storage or filesystem.
|
||||
*/
|
||||
private async getFileLines(relativePath: string, ctx: ToolContext): Promise<string[]> {
|
||||
const fileData = await ctx.storage.getFile(relativePath)
|
||||
if (fileData) {
|
||||
return fileData.lines
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
try {
|
||||
const content = await fs.readFile(absolutePath, "utf-8")
|
||||
return content.split("\n")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar symbol names for suggestions.
|
||||
*/
|
||||
private findSimilarSymbols(symbol: string, symbolIndex: Map<string, unknown>): string[] {
|
||||
const suggestions: string[] = []
|
||||
const lowerSymbol = symbol.toLowerCase()
|
||||
const maxSuggestions = 5
|
||||
|
||||
for (const name of symbolIndex.keys()) {
|
||||
if (suggestions.length >= maxSuggestions) {
|
||||
break
|
||||
}
|
||||
|
||||
const lowerName = name.toLowerCase()
|
||||
if (lowerName.includes(lowerSymbol) || lowerSymbol.includes(lowerName)) {
|
||||
suggestions.push(name)
|
||||
} else if (this.levenshteinDistance(lowerSymbol, lowerName) <= 2) {
|
||||
suggestions.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings.
|
||||
*/
|
||||
private levenshteinDistance(a: string, b: string): number {
|
||||
if (a.length === 0) {
|
||||
return b.length
|
||||
}
|
||||
if (b.length === 0) {
|
||||
return a.length
|
||||
}
|
||||
|
||||
const matrix: number[][] = []
|
||||
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i]
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1]
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j - 1] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j] + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length][a.length]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* A single reference to a symbol.
|
||||
*/
|
||||
export interface SymbolReference {
|
||||
path: string
|
||||
line: number
|
||||
column: number
|
||||
context: string
|
||||
isDefinition: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from find_references tool.
|
||||
*/
|
||||
export interface FindReferencesResult {
|
||||
symbol: string
|
||||
totalReferences: number
|
||||
files: number
|
||||
references: SymbolReference[]
|
||||
definitionLocations: {
|
||||
path: string
|
||||
line: number
|
||||
type: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for finding all usages of a symbol across the codebase.
|
||||
* Searches through indexed files for symbol references.
|
||||
*/
|
||||
export class FindReferencesTool implements ITool {
|
||||
readonly name = "find_references"
|
||||
readonly description =
|
||||
"Find all usages of a symbol across the codebase. " +
|
||||
"Returns list of file paths, line numbers, and context."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "symbol",
|
||||
type: "string",
|
||||
description: "Symbol name to search for (function, class, variable, etc.)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Limit search to specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "search" as const
|
||||
|
||||
private readonly contextLines = 1
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (typeof params.symbol !== "string" || params.symbol.trim() === "") {
|
||||
return "Parameter 'symbol' is required and must be a non-empty string"
|
||||
}
|
||||
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const symbol = (params.symbol as string).trim()
|
||||
const filterPath = params.path as string | undefined
|
||||
|
||||
try {
|
||||
const symbolIndex = await ctx.storage.getSymbolIndex()
|
||||
const definitionLocations = symbolIndex.get(symbol) ?? []
|
||||
|
||||
const allFiles = await ctx.storage.getAllFiles()
|
||||
const filesToSearch = this.filterFiles(allFiles, filterPath, ctx.projectRoot)
|
||||
|
||||
if (filesToSearch.size === 0) {
|
||||
return createSuccessResult(
|
||||
callId,
|
||||
{
|
||||
symbol,
|
||||
totalReferences: 0,
|
||||
files: 0,
|
||||
references: [],
|
||||
definitionLocations: definitionLocations.map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line,
|
||||
type: loc.type,
|
||||
})),
|
||||
} satisfies FindReferencesResult,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const references: SymbolReference[] = []
|
||||
const filesWithReferences = new Set<string>()
|
||||
|
||||
for (const [filePath, fileData] of filesToSearch) {
|
||||
const fileRefs = this.findReferencesInFile(
|
||||
filePath,
|
||||
fileData.lines,
|
||||
symbol,
|
||||
definitionLocations,
|
||||
)
|
||||
|
||||
if (fileRefs.length > 0) {
|
||||
filesWithReferences.add(filePath)
|
||||
references.push(...fileRefs)
|
||||
}
|
||||
}
|
||||
|
||||
references.sort((a, b) => {
|
||||
const pathCompare = a.path.localeCompare(b.path)
|
||||
if (pathCompare !== 0) {
|
||||
return pathCompare
|
||||
}
|
||||
return a.line - b.line
|
||||
})
|
||||
|
||||
const result: FindReferencesResult = {
|
||||
symbol,
|
||||
totalReferences: references.length,
|
||||
files: filesWithReferences.size,
|
||||
references,
|
||||
definitionLocations: definitionLocations.map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line,
|
||||
type: loc.type,
|
||||
})),
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter files by path prefix if specified.
|
||||
*/
|
||||
private filterFiles(
|
||||
allFiles: Map<string, { lines: string[] }>,
|
||||
filterPath: string | undefined,
|
||||
projectRoot: string,
|
||||
): Map<string, { lines: string[] }> {
|
||||
if (!filterPath) {
|
||||
return allFiles
|
||||
}
|
||||
|
||||
const normalizedFilter = filterPath.startsWith("/")
|
||||
? path.relative(projectRoot, filterPath)
|
||||
: filterPath
|
||||
|
||||
const filtered = new Map<string, { lines: string[] }>()
|
||||
for (const [filePath, fileData] of allFiles) {
|
||||
if (filePath === normalizedFilter || filePath.startsWith(`${normalizedFilter}/`)) {
|
||||
filtered.set(filePath, fileData)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all references to the symbol in a file.
|
||||
*/
|
||||
private findReferencesInFile(
|
||||
filePath: string,
|
||||
lines: string[],
|
||||
symbol: string,
|
||||
definitionLocations: { path: string; line: number }[],
|
||||
): SymbolReference[] {
|
||||
const references: SymbolReference[] = []
|
||||
const symbolRegex = this.createSymbolRegex(symbol)
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||
const line = lines[lineIndex]
|
||||
const lineNumber = lineIndex + 1
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
symbolRegex.lastIndex = 0
|
||||
while ((match = symbolRegex.exec(line)) !== null) {
|
||||
const column = match.index + 1
|
||||
const context = this.buildContext(lines, lineIndex)
|
||||
const isDefinition = this.isDefinitionLine(
|
||||
filePath,
|
||||
lineNumber,
|
||||
definitionLocations,
|
||||
)
|
||||
|
||||
references.push({
|
||||
path: filePath,
|
||||
line: lineNumber,
|
||||
column,
|
||||
context,
|
||||
isDefinition,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a regex for matching the symbol with appropriate boundaries.
|
||||
* Handles symbols that start or end with non-word characters (like $value).
|
||||
*/
|
||||
private createSymbolRegex(symbol: string): RegExp {
|
||||
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const startsWithWordChar = /^\w/.test(symbol)
|
||||
const endsWithWordChar = /\w$/.test(symbol)
|
||||
|
||||
const prefix = startsWithWordChar ? "\\b" : "(?<![\\w$])"
|
||||
const suffix = endsWithWordChar ? "\\b" : "(?![\\w$])"
|
||||
|
||||
return new RegExp(`${prefix}${escaped}${suffix}`, "g")
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context string with surrounding lines.
|
||||
*/
|
||||
private buildContext(lines: string[], currentIndex: number): string {
|
||||
const startIndex = Math.max(0, currentIndex - this.contextLines)
|
||||
const endIndex = Math.min(lines.length - 1, currentIndex + this.contextLines)
|
||||
|
||||
const contextLines: string[] = []
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const lineNum = i + 1
|
||||
const prefix = i === currentIndex ? ">" : " "
|
||||
contextLines.push(`${prefix}${String(lineNum).padStart(4)}│${lines[i]}`)
|
||||
}
|
||||
|
||||
return contextLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this line is a definition location.
|
||||
*/
|
||||
private isDefinitionLine(
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
definitionLocations: { path: string; line: number }[],
|
||||
): boolean {
|
||||
return definitionLocations.some((loc) => loc.path === filePath && loc.line === lineNumber)
|
||||
}
|
||||
}
|
||||
12
packages/ipuaro/src/infrastructure/tools/search/index.ts
Normal file
12
packages/ipuaro/src/infrastructure/tools/search/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Search tools exports
|
||||
export {
|
||||
FindReferencesTool,
|
||||
type FindReferencesResult,
|
||||
type SymbolReference,
|
||||
} from "./FindReferencesTool.js"
|
||||
|
||||
export {
|
||||
FindDefinitionTool,
|
||||
type FindDefinitionResult,
|
||||
type DefinitionLocation,
|
||||
} from "./FindDefinitionTool.js"
|
||||
@@ -63,6 +63,13 @@ describe("ChatMessage", () => {
|
||||
|
||||
expect(msg.content).toContain("[2] Error: Not found")
|
||||
})
|
||||
|
||||
it("should handle error result without error message", () => {
|
||||
const results = [{ callId: "3", success: false, executionTimeMs: 5 }]
|
||||
const msg = createToolMessage(results)
|
||||
|
||||
expect(msg.content).toContain("[3] Error: Unknown error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSystemMessage", () => {
|
||||
|
||||
@@ -301,6 +301,66 @@ describe("ASTParser", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("import string formats", () => {
|
||||
it("should handle single-quoted imports", () => {
|
||||
const code = `import { foo } from './module'`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.imports).toHaveLength(1)
|
||||
expect(ast.imports[0].from).toBe("./module")
|
||||
})
|
||||
|
||||
it("should handle double-quoted imports", () => {
|
||||
const code = `import { bar } from "./other"`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.imports).toHaveLength(1)
|
||||
expect(ast.imports[0].from).toBe("./other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parameter types", () => {
|
||||
it("should handle simple identifier parameters", () => {
|
||||
const code = `const fn = (x) => x * 2`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle optional parameters with defaults", () => {
|
||||
const code = `function greet(name: string = "World"): string { return name }`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions).toHaveLength(1)
|
||||
const fn = ast.functions[0]
|
||||
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle arrow function with untyped params", () => {
|
||||
const code = `const add = (a, b) => a + b`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle multiple parameter types", () => {
|
||||
const code = `
|
||||
function mix(
|
||||
required: string,
|
||||
optional?: number,
|
||||
withDefault: boolean = true
|
||||
) {}
|
||||
`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions).toHaveLength(1)
|
||||
const fn = ast.functions[0]
|
||||
expect(fn.params).toHaveLength(3)
|
||||
expect(fn.params.some((p) => p.optional)).toBe(true)
|
||||
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("complex file", () => {
|
||||
it("should parse complex TypeScript file", () => {
|
||||
const code = `
|
||||
|
||||
@@ -212,6 +212,32 @@ describe("FileScanner", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty file handling", () => {
|
||||
it("should consider empty files as text files", async () => {
|
||||
const emptyFile = path.join(FIXTURES_DIR, "empty-file.ts")
|
||||
await fs.writeFile(emptyFile, "")
|
||||
|
||||
try {
|
||||
const isText = await FileScanner.isTextFile(emptyFile)
|
||||
expect(isText).toBe(true)
|
||||
} finally {
|
||||
await fs.unlink(emptyFile)
|
||||
}
|
||||
})
|
||||
|
||||
it("should read empty file content", async () => {
|
||||
const emptyFile = path.join(FIXTURES_DIR, "empty-content.ts")
|
||||
await fs.writeFile(emptyFile, "")
|
||||
|
||||
try {
|
||||
const content = await FileScanner.readFileContent(emptyFile)
|
||||
expect(content).toBe("")
|
||||
} finally {
|
||||
await fs.unlink(emptyFile)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty directory handling", () => {
|
||||
let emptyDir: string
|
||||
|
||||
|
||||
@@ -605,4 +605,44 @@ export type ServiceResult<T> = { success: true; data: T } | { success: false; er
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("jsx to tsx resolution", () => {
|
||||
it("should resolve .jsx imports to .tsx files", () => {
|
||||
const mainCode = `import { Button } from "./Button.jsx"`
|
||||
const buttonCode = `export function Button() { return null }`
|
||||
|
||||
const asts = new Map<string, FileAST>([
|
||||
["/project/src/main.ts", parser.parse(mainCode, "ts")],
|
||||
["/project/src/Button.tsx", parser.parse(buttonCode, "tsx")],
|
||||
])
|
||||
|
||||
const graph = builder.buildDepsGraph(asts)
|
||||
|
||||
expect(graph.imports.get("/project/src/main.ts")).toContain("/project/src/Button.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty deps graph for circular dependencies", () => {
|
||||
const graph = {
|
||||
imports: new Map<string, string[]>(),
|
||||
importedBy: new Map<string, string[]>(),
|
||||
}
|
||||
|
||||
const cycles = builder.findCircularDependencies(graph)
|
||||
expect(cycles).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle single file with no imports", () => {
|
||||
const code = `export const x = 1`
|
||||
const asts = new Map<string, FileAST>([
|
||||
["/project/src/single.ts", parser.parse(code, "ts")],
|
||||
])
|
||||
|
||||
const graph = builder.buildDepsGraph(asts)
|
||||
const cycles = builder.findCircularDependencies(graph)
|
||||
|
||||
expect(cycles).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -544,6 +544,44 @@ const b = 2`
|
||||
})
|
||||
})
|
||||
|
||||
describe("dependency resolution with different extensions", () => {
|
||||
it("should resolve imports from index files", () => {
|
||||
const content = `import { utils } from "./utils/index"`
|
||||
const ast = parser.parse(content, "ts")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
allASTs.set("/project/src/main.ts", ast)
|
||||
allASTs.set("/project/src/utils/index.ts", createEmptyFileAST())
|
||||
|
||||
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
|
||||
|
||||
expect(meta.dependencies).toContain("/project/src/utils/index.ts")
|
||||
})
|
||||
|
||||
it("should convert .js extension to .ts when resolving", () => {
|
||||
const content = `import { helper } from "./helper.js"`
|
||||
const ast = parser.parse(content, "ts")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
allASTs.set("/project/src/main.ts", ast)
|
||||
allASTs.set("/project/src/helper.ts", createEmptyFileAST())
|
||||
|
||||
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
|
||||
|
||||
expect(meta.dependencies).toContain("/project/src/helper.ts")
|
||||
})
|
||||
|
||||
it("should convert .jsx extension to .tsx when resolving", () => {
|
||||
const content = `import { Button } from "./Button.jsx"`
|
||||
const ast = parser.parse(content, "ts")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
allASTs.set("/project/src/App.tsx", ast)
|
||||
allASTs.set("/project/src/Button.tsx", createEmptyFileAST())
|
||||
|
||||
const meta = analyzer.analyze("/project/src/App.tsx", ast, content, allASTs)
|
||||
|
||||
expect(meta.dependencies).toContain("/project/src/Button.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("analyze", () => {
|
||||
it("should produce complete FileMeta", () => {
|
||||
const content = `import { helper } from "./helper"
|
||||
|
||||
@@ -94,12 +94,70 @@ describe("Watchdog", () => {
|
||||
it("should return empty array when not watching", () => {
|
||||
expect(watchdog.getWatchedPaths()).toEqual([])
|
||||
})
|
||||
|
||||
it("should return paths when watching", async () => {
|
||||
const testFile = path.join(tempDir, "exists.ts")
|
||||
await fs.writeFile(testFile, "const x = 1")
|
||||
|
||||
watchdog.start(tempDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
const paths = watchdog.getWatchedPaths()
|
||||
expect(Array.isArray(paths)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("flushAll", () => {
|
||||
it("should not throw when no pending changes", () => {
|
||||
expect(() => watchdog.flushAll()).not.toThrow()
|
||||
})
|
||||
|
||||
it("should flush all pending changes", async () => {
|
||||
const events: FileChangeEvent[] = []
|
||||
watchdog.onFileChange((event) => events.push(event))
|
||||
watchdog.start(tempDir)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const testFile = path.join(tempDir, "flush-test.ts")
|
||||
await fs.writeFile(testFile, "const x = 1")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
watchdog.flushAll()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
})
|
||||
|
||||
describe("ignore patterns", () => {
|
||||
it("should handle glob patterns with wildcards", async () => {
|
||||
const customWatchdog = new Watchdog({
|
||||
debounceMs: 50,
|
||||
ignorePatterns: ["*.log", "**/*.tmp"],
|
||||
})
|
||||
|
||||
customWatchdog.start(tempDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(customWatchdog.isWatching()).toBe(true)
|
||||
|
||||
await customWatchdog.stop()
|
||||
})
|
||||
|
||||
it("should handle simple directory patterns", async () => {
|
||||
const customWatchdog = new Watchdog({
|
||||
debounceMs: 50,
|
||||
ignorePatterns: ["node_modules", "dist"],
|
||||
})
|
||||
|
||||
customWatchdog.start(tempDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(customWatchdog.isWatching()).toBe(true)
|
||||
|
||||
await customWatchdog.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("file change detection", () => {
|
||||
|
||||
@@ -301,4 +301,188 @@ describe("OllamaClient", () => {
|
||||
expect(() => client.abort()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("message conversion", () => {
|
||||
it("should convert system messages", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: "You are a helpful assistant",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "system",
|
||||
content: "You are a helpful assistant",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert tool result messages", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [
|
||||
{
|
||||
role: "tool" as const,
|
||||
content: '{"result": "success"}',
|
||||
timestamp: Date.now(),
|
||||
toolResults: [
|
||||
{ callId: "call_1", success: true, data: "success", executionTimeMs: 10 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
content: '{"result": "success"}',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert assistant messages with tool calls", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "I will read the file",
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [{ id: "call_1", name: "get_lines", params: { path: "test.ts" } }],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "I will read the file",
|
||||
tool_calls: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
function: expect.objectContaining({
|
||||
name: "get_lines",
|
||||
arguments: { path: "test.ts" },
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response handling", () => {
|
||||
it("should estimate tokens when eval_count is undefined", async () => {
|
||||
mockOllamaInstance.chat.mockResolvedValue({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello world response",
|
||||
tool_calls: undefined,
|
||||
},
|
||||
eval_count: undefined,
|
||||
done_reason: "stop",
|
||||
})
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const response = await client.chat([createUserMessage("Hello")])
|
||||
|
||||
expect(response.tokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should return length stop reason", async () => {
|
||||
mockOllamaInstance.chat.mockResolvedValue({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Truncated...",
|
||||
tool_calls: undefined,
|
||||
},
|
||||
eval_count: 100,
|
||||
done_reason: "length",
|
||||
})
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const response = await client.chat([createUserMessage("Hello")])
|
||||
|
||||
expect(response.stopReason).toBe("length")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool parameter conversion", () => {
|
||||
it("should include enum values when present", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Get status")]
|
||||
const tools = [
|
||||
{
|
||||
name: "get_status",
|
||||
description: "Get status",
|
||||
parameters: [
|
||||
{
|
||||
name: "type",
|
||||
type: "string" as const,
|
||||
description: "Status type",
|
||||
required: true,
|
||||
enum: ["active", "inactive", "pending"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages, tools)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
function: expect.objectContaining({
|
||||
parameters: expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
type: expect.objectContaining({
|
||||
enum: ["active", "inactive", "pending"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle ECONNREFUSED errors", async () => {
|
||||
mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(
|
||||
/Cannot connect to Ollama/,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle generic errors with context", async () => {
|
||||
mockOllamaInstance.pull.mockRejectedValue(new Error("Unknown error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.pullModel("test")).rejects.toThrow(/Failed to pull model/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,6 +249,445 @@ describe("prompts", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildFileContext - edge cases", () => {
|
||||
it("should handle empty imports", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("empty.ts", ast)
|
||||
|
||||
expect(context).toContain("## empty.ts")
|
||||
expect(context).not.toContain("### Imports")
|
||||
})
|
||||
|
||||
it("should handle empty exports", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [{ name: "x", from: "./x", line: 1, type: "internal", isDefault: false }],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("no-exports.ts", ast)
|
||||
|
||||
expect(context).toContain("### Imports")
|
||||
expect(context).not.toContain("### Exports")
|
||||
})
|
||||
|
||||
it("should handle empty functions", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "MyClass",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
methods: [],
|
||||
properties: [],
|
||||
implements: [],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("no-functions.ts", ast)
|
||||
|
||||
expect(context).not.toContain("### Functions")
|
||||
expect(context).toContain("### Classes")
|
||||
})
|
||||
|
||||
it("should handle empty classes", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [
|
||||
{
|
||||
name: "test",
|
||||
lineStart: 1,
|
||||
lineEnd: 5,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
isExported: false,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("no-classes.ts", ast)
|
||||
|
||||
expect(context).toContain("### Functions")
|
||||
expect(context).not.toContain("### Classes")
|
||||
})
|
||||
|
||||
it("should handle class without extends", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "Standalone",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
methods: [],
|
||||
properties: [],
|
||||
implements: ["IFoo"],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("standalone.ts", ast)
|
||||
|
||||
expect(context).toContain("Standalone implements IFoo")
|
||||
expect(context).not.toContain("extends")
|
||||
})
|
||||
|
||||
it("should handle class without implements", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "Child",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
methods: [],
|
||||
properties: [],
|
||||
extends: "Parent",
|
||||
implements: [],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("child.ts", ast)
|
||||
|
||||
expect(context).toContain("Child extends Parent")
|
||||
expect(context).not.toContain("implements")
|
||||
})
|
||||
|
||||
it("should handle method with private visibility", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "WithPrivate",
|
||||
lineStart: 1,
|
||||
lineEnd: 20,
|
||||
methods: [
|
||||
{
|
||||
name: "secretMethod",
|
||||
lineStart: 5,
|
||||
lineEnd: 10,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
visibility: "private",
|
||||
isStatic: false,
|
||||
},
|
||||
],
|
||||
properties: [],
|
||||
implements: [],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("private.ts", ast)
|
||||
|
||||
expect(context).toContain("private secretMethod()")
|
||||
})
|
||||
|
||||
it("should handle non-async function", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [
|
||||
{
|
||||
name: "syncFn",
|
||||
lineStart: 1,
|
||||
lineEnd: 5,
|
||||
params: [{ name: "x", optional: false, hasDefault: false }],
|
||||
isAsync: false,
|
||||
isExported: false,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("sync.ts", ast)
|
||||
|
||||
expect(context).toContain("syncFn(x)")
|
||||
expect(context).not.toContain("async syncFn")
|
||||
})
|
||||
|
||||
it("should handle export without default", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [{ name: "foo", line: 1, isDefault: false, kind: "variable" }],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("named-export.ts", ast)
|
||||
|
||||
expect(context).toContain("variable foo")
|
||||
expect(context).not.toContain("(default)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildInitialContext - edge cases", () => {
|
||||
it("should handle nested directory names", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: [],
|
||||
directories: ["src/components/ui"],
|
||||
}
|
||||
const asts = new Map<string, FileAST>()
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("ui/")
|
||||
})
|
||||
|
||||
it("should handle file with only interfaces", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["types.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"types.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [{ name: "IFoo", lineStart: 1, lineEnd: 5, isExported: true }],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("interface: IFoo")
|
||||
})
|
||||
|
||||
it("should handle file with only type aliases", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["types.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"types.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [
|
||||
{ name: "MyType", lineStart: 1, lineEnd: 1, isExported: true },
|
||||
],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("type: MyType")
|
||||
})
|
||||
|
||||
it("should handle file with no AST content", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["empty.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"empty.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("- empty.ts")
|
||||
})
|
||||
|
||||
it("should handle meta with only hub flag", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["hub.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"hub.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"hub.ts",
|
||||
{
|
||||
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: true,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts, metas)
|
||||
|
||||
expect(context).toContain("(hub)")
|
||||
expect(context).not.toContain("entry")
|
||||
expect(context).not.toContain("complex")
|
||||
})
|
||||
|
||||
it("should handle meta with no flags", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["normal.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"normal.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"normal.ts",
|
||||
{
|
||||
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: false,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts, metas)
|
||||
|
||||
expect(context).toContain("- normal.ts")
|
||||
expect(context).not.toContain("(hub")
|
||||
expect(context).not.toContain("entry")
|
||||
expect(context).not.toContain("complex")
|
||||
})
|
||||
|
||||
it("should skip files not in AST map", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["exists.ts", "missing.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"exists.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("exists.ts")
|
||||
expect(context).not.toContain("missing.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateContext", () => {
|
||||
it("should return original context if within limit", () => {
|
||||
const context = "Short context"
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GetComplexityTool,
|
||||
type GetComplexityResult,
|
||||
} from "../../../../../src/infrastructure/tools/analysis/GetComplexityTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { FileMeta } from "../../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
function createMockFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
|
||||
return {
|
||||
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: false,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStorage(metas: Map<string, FileMeta> = new Map()): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(null),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn().mockResolvedValue(null),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn().mockImplementation((p: string) => Promise.resolve(metas.get(p) ?? null)),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(metas),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("GetComplexityTool", () => {
|
||||
let tool: GetComplexityTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GetComplexityTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("get_complexity")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("analysis")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(false)
|
||||
expect(tool.parameters[1].name).toBe("limit")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("complexity")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for no params", () => {
|
||||
expect(tool.validateParams({})).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid path", () => {
|
||||
expect(tool.validateParams({ path: "src/index.ts" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid limit", () => {
|
||||
expect(tool.validateParams({ limit: 10 })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid path and limit", () => {
|
||||
expect(tool.validateParams({ path: "src", limit: 5 })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toBe("Parameter 'path' must be a string")
|
||||
})
|
||||
|
||||
it("should return error for non-integer limit", () => {
|
||||
expect(tool.validateParams({ limit: 10.5 })).toBe(
|
||||
"Parameter 'limit' must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-number limit", () => {
|
||||
expect(tool.validateParams({ limit: "10" })).toBe(
|
||||
"Parameter 'limit' must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for limit less than 1", () => {
|
||||
expect(tool.validateParams({ limit: 0 })).toBe("Parameter 'limit' must be at least 1")
|
||||
})
|
||||
|
||||
it("should return error for negative limit", () => {
|
||||
expect(tool.validateParams({ limit: -5 })).toBe("Parameter 'limit' must be at least 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("should return complexity for all files without path", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/a.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 50 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/b.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 25 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.analyzedPath).toBeNull()
|
||||
expect(data.totalFiles).toBe(2)
|
||||
expect(data.files).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should sort files by complexity score descending", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/low.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 2, score: 10 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/high.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 25, score: 80 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/mid.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 50, nesting: 3, cyclomaticComplexity: 10, score: 40 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.files[0].path).toBe("src/high.ts")
|
||||
expect(data.files[1].path).toBe("src/mid.ts")
|
||||
expect(data.files[2].path).toBe("src/low.ts")
|
||||
})
|
||||
|
||||
it("should filter by path prefix", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/a.ts", createMockFileMeta()],
|
||||
["src/b.ts", createMockFileMeta()],
|
||||
["lib/c.ts", createMockFileMeta()],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.analyzedPath).toBe("src")
|
||||
expect(data.totalFiles).toBe(2)
|
||||
expect(data.files.every((f) => f.path.startsWith("src/"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should filter by specific file path", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/a.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 15, score: 55 },
|
||||
}),
|
||||
],
|
||||
["src/b.ts", createMockFileMeta()],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/a.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.totalFiles).toBe(1)
|
||||
expect(data.files[0].path).toBe("src/a.ts")
|
||||
expect(data.files[0].metrics.score).toBe(55)
|
||||
})
|
||||
|
||||
it("should respect limit parameter", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/a.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 70 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/b.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 50 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/c.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 2, score: 20 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ limit: 2 }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.totalFiles).toBe(3)
|
||||
expect(data.files).toHaveLength(2)
|
||||
expect(data.files[0].metrics.score).toBe(70)
|
||||
expect(data.files[1].metrics.score).toBe(50)
|
||||
})
|
||||
|
||||
it("should use default limit of 20", async () => {
|
||||
const metas = new Map<string, FileMeta>()
|
||||
for (let i = 0; i < 30; i++) {
|
||||
metas.set(`src/file${String(i)}.ts`, createMockFileMeta())
|
||||
}
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.totalFiles).toBe(30)
|
||||
expect(data.files).toHaveLength(20)
|
||||
})
|
||||
|
||||
it("should calculate average score", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/a.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 60 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/b.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 40 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.averageScore).toBe(50)
|
||||
})
|
||||
|
||||
it("should calculate summary statistics", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/high.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 25, score: 75 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/medium.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 80, nesting: 3, cyclomaticComplexity: 12, score: 45 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/low.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 3, score: 15 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.summary.highComplexity).toBe(1)
|
||||
expect(data.summary.mediumComplexity).toBe(1)
|
||||
expect(data.summary.lowComplexity).toBe(1)
|
||||
})
|
||||
|
||||
it("should return empty result for empty project", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.totalFiles).toBe(0)
|
||||
expect(data.averageScore).toBe(0)
|
||||
expect(data.files).toEqual([])
|
||||
expect(data.summary).toEqual({
|
||||
highComplexity: 0,
|
||||
mediumComplexity: 0,
|
||||
lowComplexity: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return error for non-existent path", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/a.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "nonexistent" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("No files found at path")
|
||||
})
|
||||
|
||||
it("should handle absolute paths", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/a.ts", createMockFileMeta()],
|
||||
["src/b.ts", createMockFileMeta()],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "/test/project/src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.analyzedPath).toBe("src")
|
||||
expect(data.totalFiles).toBe(2)
|
||||
})
|
||||
|
||||
it("should include file metadata", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/hub.ts",
|
||||
createMockFileMeta({
|
||||
fileType: "source",
|
||||
isHub: true,
|
||||
complexity: { loc: 150, nesting: 4, cyclomaticComplexity: 18, score: 65 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.files[0].fileType).toBe("source")
|
||||
expect(data.files[0].isHub).toBe(true)
|
||||
expect(data.files[0].metrics).toEqual({
|
||||
loc: 150,
|
||||
nesting: 4,
|
||||
cyclomaticComplexity: 18,
|
||||
score: 65,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/a.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^get_complexity-\d+$/)
|
||||
})
|
||||
|
||||
it("should include execution time in result", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/a.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = createMockStorage()
|
||||
;(storage.getAllMetas as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Redis connection failed"),
|
||||
)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Redis connection failed")
|
||||
})
|
||||
|
||||
it("should round average score to 2 decimal places", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/a.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 33 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/b.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 33 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/c.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 2, score: 34 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.averageScore).toBe(33.33)
|
||||
})
|
||||
|
||||
it("should handle complexity threshold boundaries", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/exact-high.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 20, score: 60 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/exact-medium.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 30 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/below-medium.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 29 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetComplexityResult
|
||||
expect(data.summary.highComplexity).toBe(1)
|
||||
expect(data.summary.mediumComplexity).toBe(1)
|
||||
expect(data.summary.lowComplexity).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GetDependenciesTool,
|
||||
type GetDependenciesResult,
|
||||
} from "../../../../../src/infrastructure/tools/analysis/GetDependenciesTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { FileMeta } from "../../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
function createMockFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
|
||||
return {
|
||||
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: false,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStorage(metas: Map<string, FileMeta> = new Map()): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(null),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn().mockResolvedValue(null),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn().mockImplementation((p: string) => Promise.resolve(metas.get(p) ?? null)),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(metas),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("GetDependenciesTool", () => {
|
||||
let tool: GetDependenciesTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GetDependenciesTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("get_dependencies")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("analysis")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(1)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("imports")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid path", () => {
|
||||
expect(tool.validateParams({ path: "src/index.ts" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({})).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for whitespace-only path", () => {
|
||||
expect(tool.validateParams({ path: " " })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("should return dependencies for a file", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileMeta({
|
||||
dependencies: ["src/utils.ts", "src/config.ts"],
|
||||
}),
|
||||
],
|
||||
["src/utils.ts", createMockFileMeta({ isHub: true })],
|
||||
["src/config.ts", createMockFileMeta({ isEntryPoint: true })],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.file).toBe("src/index.ts")
|
||||
expect(data.totalDependencies).toBe(2)
|
||||
expect(data.dependencies).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should include metadata for each dependency", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileMeta({
|
||||
dependencies: ["src/utils.ts"],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileMeta({
|
||||
isHub: true,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.dependencies[0]).toEqual({
|
||||
path: "src/utils.ts",
|
||||
exists: true,
|
||||
isEntryPoint: false,
|
||||
isHub: true,
|
||||
fileType: "source",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle file with no dependencies", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/standalone.ts", createMockFileMeta({ dependencies: [] })],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/standalone.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.totalDependencies).toBe(0)
|
||||
expect(data.dependencies).toEqual([])
|
||||
})
|
||||
|
||||
it("should return error for non-existent file", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("File not found or not indexed")
|
||||
})
|
||||
|
||||
it("should handle absolute paths", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/index.ts", createMockFileMeta({ dependencies: [] })],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "/test/project/src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.file).toBe("src/index.ts")
|
||||
})
|
||||
|
||||
it("should mark non-existent dependencies", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileMeta({
|
||||
dependencies: ["src/missing.ts"],
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.dependencies[0].exists).toBe(false)
|
||||
expect(data.dependencies[0].isHub).toBe(false)
|
||||
expect(data.dependencies[0].fileType).toBe("unknown")
|
||||
})
|
||||
|
||||
it("should sort dependencies by path", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileMeta({
|
||||
dependencies: ["src/z.ts", "src/a.ts", "src/m.ts"],
|
||||
}),
|
||||
],
|
||||
["src/z.ts", createMockFileMeta()],
|
||||
["src/a.ts", createMockFileMeta()],
|
||||
["src/m.ts", createMockFileMeta()],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.dependencies[0].path).toBe("src/a.ts")
|
||||
expect(data.dependencies[1].path).toBe("src/m.ts")
|
||||
expect(data.dependencies[2].path).toBe("src/z.ts")
|
||||
})
|
||||
|
||||
it("should include file type of source file", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"tests/index.test.ts",
|
||||
createMockFileMeta({
|
||||
fileType: "test",
|
||||
dependencies: [],
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "tests/index.test.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.fileType).toBe("test")
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/index.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^get_dependencies-\d+$/)
|
||||
})
|
||||
|
||||
it("should include execution time in result", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/index.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = createMockStorage()
|
||||
;(storage.getMeta as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Redis connection failed"),
|
||||
)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Redis connection failed")
|
||||
})
|
||||
|
||||
it("should trim path before searching", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/index.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: " src/index.ts " }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.file).toBe("src/index.ts")
|
||||
})
|
||||
|
||||
it("should handle many dependencies", async () => {
|
||||
const deps = Array.from({ length: 50 }, (_, i) => `src/dep${String(i)}.ts`)
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/index.ts", createMockFileMeta({ dependencies: deps })],
|
||||
...deps.map((dep) => [dep, createMockFileMeta()] as [string, FileMeta]),
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/index.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependenciesResult
|
||||
expect(data.totalDependencies).toBe(50)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GetDependentsTool,
|
||||
type GetDependentsResult,
|
||||
} from "../../../../../src/infrastructure/tools/analysis/GetDependentsTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { FileMeta } from "../../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
function createMockFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
|
||||
return {
|
||||
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 25 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: false,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStorage(metas: Map<string, FileMeta> = new Map()): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(null),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn().mockResolvedValue(null),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn().mockImplementation((p: string) => Promise.resolve(metas.get(p) ?? null)),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(metas),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("GetDependentsTool", () => {
|
||||
let tool: GetDependentsTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GetDependentsTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("get_dependents")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("analysis")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(1)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("import")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid path", () => {
|
||||
expect(tool.validateParams({ path: "src/utils.ts" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({})).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for whitespace-only path", () => {
|
||||
expect(tool.validateParams({ path: " " })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("should return dependents for a file", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileMeta({
|
||||
dependents: ["src/index.ts", "src/app.ts"],
|
||||
isHub: true,
|
||||
}),
|
||||
],
|
||||
["src/index.ts", createMockFileMeta({ isEntryPoint: true })],
|
||||
["src/app.ts", createMockFileMeta()],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.file).toBe("src/utils.ts")
|
||||
expect(data.totalDependents).toBe(2)
|
||||
expect(data.isHub).toBe(true)
|
||||
expect(data.dependents).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should include metadata for each dependent", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileMeta({
|
||||
dependents: ["src/index.ts"],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileMeta({
|
||||
isHub: false,
|
||||
isEntryPoint: true,
|
||||
fileType: "source",
|
||||
complexity: { loc: 50, nesting: 3, cyclomaticComplexity: 10, score: 45 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.dependents[0]).toEqual({
|
||||
path: "src/index.ts",
|
||||
isEntryPoint: true,
|
||||
isHub: false,
|
||||
fileType: "source",
|
||||
complexityScore: 45,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle file with no dependents", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/isolated.ts", createMockFileMeta({ dependents: [] })],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/isolated.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.totalDependents).toBe(0)
|
||||
expect(data.isHub).toBe(false)
|
||||
expect(data.dependents).toEqual([])
|
||||
})
|
||||
|
||||
it("should return error for non-existent file", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("File not found or not indexed")
|
||||
})
|
||||
|
||||
it("should handle absolute paths", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
["src/utils.ts", createMockFileMeta({ dependents: [] })],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "/test/project/src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.file).toBe("src/utils.ts")
|
||||
})
|
||||
|
||||
it("should handle missing dependent metadata", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileMeta({
|
||||
dependents: ["src/missing.ts"],
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.dependents[0].isHub).toBe(false)
|
||||
expect(data.dependents[0].isEntryPoint).toBe(false)
|
||||
expect(data.dependents[0].fileType).toBe("unknown")
|
||||
expect(data.dependents[0].complexityScore).toBe(0)
|
||||
})
|
||||
|
||||
it("should sort dependents by path", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileMeta({
|
||||
dependents: ["src/z.ts", "src/a.ts", "src/m.ts"],
|
||||
}),
|
||||
],
|
||||
["src/z.ts", createMockFileMeta()],
|
||||
["src/a.ts", createMockFileMeta()],
|
||||
["src/m.ts", createMockFileMeta()],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.dependents[0].path).toBe("src/a.ts")
|
||||
expect(data.dependents[1].path).toBe("src/m.ts")
|
||||
expect(data.dependents[2].path).toBe("src/z.ts")
|
||||
})
|
||||
|
||||
it("should include file type of source file", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/types.ts",
|
||||
createMockFileMeta({
|
||||
fileType: "types",
|
||||
dependents: [],
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/types.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.fileType).toBe("types")
|
||||
})
|
||||
|
||||
it("should correctly identify hub files", async () => {
|
||||
const dependents = Array.from({ length: 10 }, (_, i) => `src/file${String(i)}.ts`)
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/core.ts",
|
||||
createMockFileMeta({
|
||||
dependents,
|
||||
isHub: true,
|
||||
}),
|
||||
],
|
||||
...dependents.map((dep) => [dep, createMockFileMeta()] as [string, FileMeta]),
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/core.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.isHub).toBe(true)
|
||||
expect(data.totalDependents).toBe(10)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/utils.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^get_dependents-\d+$/)
|
||||
})
|
||||
|
||||
it("should include execution time in result", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/utils.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = createMockStorage()
|
||||
;(storage.getMeta as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Redis connection failed"),
|
||||
)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Redis connection failed")
|
||||
})
|
||||
|
||||
it("should trim path before searching", async () => {
|
||||
const metas = new Map<string, FileMeta>([["src/utils.ts", createMockFileMeta()]])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: " src/utils.ts " }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
expect(data.file).toBe("src/utils.ts")
|
||||
})
|
||||
|
||||
it("should include complexity scores for dependents", async () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileMeta({
|
||||
dependents: ["src/high.ts", "src/low.ts"],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/high.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 200, nesting: 5, cyclomaticComplexity: 20, score: 80 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"src/low.ts",
|
||||
createMockFileMeta({
|
||||
complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 2, score: 10 },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(metas)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/utils.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetDependentsResult
|
||||
const highDep = data.dependents.find((d) => d.path === "src/high.ts")
|
||||
const lowDep = data.dependents.find((d) => d.path === "src/low.ts")
|
||||
expect(highDep?.complexityScore).toBe(80)
|
||||
expect(lowDep?.complexityScore).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,583 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GetTodosTool,
|
||||
type GetTodosResult,
|
||||
} from "../../../../../src/infrastructure/tools/analysis/GetTodosTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { FileData } from "../../../../../src/domain/value-objects/FileData.js"
|
||||
|
||||
function createMockFileData(lines: string[]): FileData {
|
||||
return {
|
||||
lines,
|
||||
hash: "abc123",
|
||||
size: lines.join("\n").length,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStorage(files: Map<string, FileData> = new Map()): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockImplementation((p: string) => Promise.resolve(files.get(p) ?? null)),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(files),
|
||||
getFileCount: vi.fn().mockResolvedValue(files.size),
|
||||
getAST: vi.fn().mockResolvedValue(null),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn().mockResolvedValue(null),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("GetTodosTool", () => {
|
||||
let tool: GetTodosTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GetTodosTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("get_todos")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("analysis")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(false)
|
||||
expect(tool.parameters[1].name).toBe("type")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("TODO")
|
||||
expect(tool.description).toContain("FIXME")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for no params", () => {
|
||||
expect(tool.validateParams({})).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid path", () => {
|
||||
expect(tool.validateParams({ path: "src" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid type", () => {
|
||||
expect(tool.validateParams({ type: "TODO" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for lowercase type", () => {
|
||||
expect(tool.validateParams({ type: "fixme" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for path and type", () => {
|
||||
expect(tool.validateParams({ path: "src", type: "TODO" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toBe("Parameter 'path' must be a string")
|
||||
})
|
||||
|
||||
it("should return error for non-string type", () => {
|
||||
expect(tool.validateParams({ type: 123 })).toBe("Parameter 'type' must be a string")
|
||||
})
|
||||
|
||||
it("should return error for invalid type", () => {
|
||||
expect(tool.validateParams({ type: "INVALID" })).toBe(
|
||||
"Parameter 'type' must be one of: TODO, FIXME, HACK, XXX, BUG, NOTE",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("should find TODO comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileData([
|
||||
"// TODO: implement this",
|
||||
"function foo() {}",
|
||||
"// TODO: add tests",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(2)
|
||||
expect(data.todos[0].type).toBe("TODO")
|
||||
expect(data.todos[0].text).toBe("implement this")
|
||||
expect(data.todos[1].text).toBe("add tests")
|
||||
})
|
||||
|
||||
it("should find FIXME comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileData(["// FIXME: broken logic here", "const x = 1"]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].type).toBe("FIXME")
|
||||
expect(data.todos[0].text).toBe("broken logic here")
|
||||
})
|
||||
|
||||
it("should find HACK comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// HACK: temporary workaround"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].type).toBe("HACK")
|
||||
})
|
||||
|
||||
it("should find XXX comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// XXX: needs attention"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].type).toBe("XXX")
|
||||
})
|
||||
|
||||
it("should find BUG comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// BUG: race condition"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].type).toBe("BUG")
|
||||
})
|
||||
|
||||
it("should find NOTE comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// NOTE: important consideration"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].type).toBe("NOTE")
|
||||
})
|
||||
|
||||
it("should find comments in block comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["/*", " * TODO: in block comment", " */"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].text).toBe("in block comment")
|
||||
})
|
||||
|
||||
it("should find comments with author annotation", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// TODO(john): fix this"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].text).toBe("fix this")
|
||||
})
|
||||
|
||||
it("should handle TODO without colon", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// TODO implement feature"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].text).toBe("implement feature")
|
||||
})
|
||||
|
||||
it("should filter by type", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileData([
|
||||
"// TODO: task one",
|
||||
"// FIXME: bug here",
|
||||
"// TODO: task two",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ type: "TODO" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(2)
|
||||
expect(data.todos.every((t) => t.type === "TODO")).toBe(true)
|
||||
})
|
||||
|
||||
it("should filter by type case-insensitively", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// TODO: task", "// FIXME: bug"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ type: "todo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].type).toBe("TODO")
|
||||
})
|
||||
|
||||
it("should filter by path", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["// TODO: in src"])],
|
||||
["lib/b.ts", createMockFileData(["// TODO: in lib"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.searchedPath).toBe("src")
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].path).toBe("src/a.ts")
|
||||
})
|
||||
|
||||
it("should filter by specific file", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["// TODO: in a"])],
|
||||
["src/b.ts", createMockFileData(["// TODO: in b"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "src/a.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].path).toBe("src/a.ts")
|
||||
})
|
||||
|
||||
it("should return error for non-existent path", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["// TODO: task"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "nonexistent" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("No files found at path")
|
||||
})
|
||||
|
||||
it("should count by type", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileData([
|
||||
"// TODO: task 1",
|
||||
"// TODO: task 2",
|
||||
"// FIXME: bug",
|
||||
"// HACK: workaround",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.byType.TODO).toBe(2)
|
||||
expect(data.byType.FIXME).toBe(1)
|
||||
expect(data.byType.HACK).toBe(1)
|
||||
expect(data.byType.XXX).toBe(0)
|
||||
expect(data.byType.BUG).toBe(0)
|
||||
expect(data.byType.NOTE).toBe(0)
|
||||
})
|
||||
|
||||
it("should count files with todos", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["// TODO: task"])],
|
||||
["src/b.ts", createMockFileData(["const x = 1"])],
|
||||
["src/c.ts", createMockFileData(["// TODO: another task"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.filesWithTodos).toBe(2)
|
||||
})
|
||||
|
||||
it("should sort results by path then line", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/b.ts", createMockFileData(["// TODO: b1", "", "// TODO: b2"])],
|
||||
["src/a.ts", createMockFileData(["// TODO: a1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].path).toBe("src/a.ts")
|
||||
expect(data.todos[1].path).toBe("src/b.ts")
|
||||
expect(data.todos[1].line).toBe(1)
|
||||
expect(data.todos[2].path).toBe("src/b.ts")
|
||||
expect(data.todos[2].line).toBe(3)
|
||||
})
|
||||
|
||||
it("should include line context", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData([" // TODO: indented task"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].context).toBe("// TODO: indented task")
|
||||
})
|
||||
|
||||
it("should return empty result for empty project", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(0)
|
||||
expect(data.filesWithTodos).toBe(0)
|
||||
expect(data.todos).toEqual([])
|
||||
})
|
||||
|
||||
it("should return empty result when no todos found", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["const x = 1", "const y = 2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle TODO without description", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// TODO:"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].text).toBe("(no description)")
|
||||
})
|
||||
|
||||
it("should handle absolute paths", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["// TODO: task"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ path: "/test/project/src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.searchedPath).toBe("src")
|
||||
})
|
||||
|
||||
it("should find todos with hash comments", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["script.sh", createMockFileData(["# TODO: shell script task"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].text).toBe("shell script task")
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^get_todos-\d+$/)
|
||||
})
|
||||
|
||||
it("should include execution time in result", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = createMockStorage()
|
||||
;(storage.getAllFiles as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Redis connection failed"),
|
||||
)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Redis connection failed")
|
||||
})
|
||||
|
||||
it("should find lowercase todo markers", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/index.ts", createMockFileData(["// todo: lowercase"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(1)
|
||||
expect(data.todos[0].type).toBe("TODO")
|
||||
})
|
||||
|
||||
it("should handle multiple files with todos", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["// TODO: a1", "// TODO: a2"])],
|
||||
["src/b.ts", createMockFileData(["// FIXME: b1"])],
|
||||
["src/c.ts", createMockFileData(["// HACK: c1", "// BUG: c2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.totalTodos).toBe(5)
|
||||
expect(data.filesWithTodos).toBe(3)
|
||||
})
|
||||
|
||||
it("should correctly identify line numbers", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileData([
|
||||
"const a = 1",
|
||||
"const b = 2",
|
||||
"// TODO: on line 3",
|
||||
"const c = 3",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GetTodosResult
|
||||
expect(data.todos[0].line).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
CreateFileTool,
|
||||
type CreateFileResult,
|
||||
} from "../../../../../src/infrastructure/tools/edit/CreateFileTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import { hashLines } from "../../../../../src/shared/utils/hash.js"
|
||||
|
||||
function createMockStorage(): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(null),
|
||||
setFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult = true,
|
||||
projectRoot = "/test/project",
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot,
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("CreateFileTool", () => {
|
||||
let tool: CreateFileTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new CreateFileTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("create_file")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("edit")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("content")
|
||||
expect(tool.parameters[1].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description mentioning confirmation", () => {
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "src/new-file.ts", content: "const x = 1" }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({ content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "", content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
expect(tool.validateParams({ path: " ", content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts" })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", content: 123 })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow empty content string", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", content: "" })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "create-file-test-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should create new file with content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const result = await tool.execute({ path: "new-file.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.path).toBe("new-file.ts")
|
||||
expect(data.lines).toBe(3)
|
||||
|
||||
const filePath = path.join(tempDir, "new-file.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe(content)
|
||||
})
|
||||
|
||||
it("should create directories if they do not exist", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "deep/nested/dir/file.ts", content: "test" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const filePath = path.join(tempDir, "deep/nested/dir/file.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe("test")
|
||||
})
|
||||
|
||||
it("should call requestConfirmation with diff info", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
|
||||
"Create new file: new-file.ts (2 lines)",
|
||||
{
|
||||
filePath: "new-file.ts",
|
||||
oldLines: [],
|
||||
newLines: ["line 1", "line 2"],
|
||||
startLine: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("should cancel creation when confirmation rejected", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, false, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "new-file.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File creation cancelled by user")
|
||||
|
||||
const filePath = path.join(tempDir, "new-file.ts")
|
||||
await expect(fs.access(filePath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should update storage after creation", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
|
||||
|
||||
expect(storage.setFile).toHaveBeenCalledWith(
|
||||
"new-file.ts",
|
||||
expect.objectContaining({
|
||||
lines: ["line 1", "line 2"],
|
||||
hash: hashLines(["line 1", "line 2"]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for path outside project root", async () => {
|
||||
const ctx = createMockContext(undefined, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
})
|
||||
|
||||
it("should return error if file already exists", async () => {
|
||||
const existingFile = path.join(tempDir, "existing.ts")
|
||||
await fs.writeFile(existingFile, "original content", "utf-8")
|
||||
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "existing.ts", content: "new content" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File already exists: existing.ts")
|
||||
|
||||
const content = await fs.readFile(existingFile, "utf-8")
|
||||
expect(content).toBe("original content")
|
||||
})
|
||||
|
||||
it("should handle empty content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "empty.ts", content: "" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.lines).toBe(1)
|
||||
|
||||
const filePath = path.join(tempDir, "empty.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe("")
|
||||
})
|
||||
|
||||
it("should handle single line content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "single.ts", content: "export const x = 1" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.lines).toBe(1)
|
||||
})
|
||||
|
||||
it("should return correct file size", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "hello world"
|
||||
const result = await tool.execute({ path: "file.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.size).toBe(Buffer.byteLength(content, "utf-8"))
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^create_file-\d+$/)
|
||||
})
|
||||
|
||||
it("should include executionTimeMs in result", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle multi-line content correctly", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "import { x } from './x'\n\nexport function foo() {\n return x\n}\n"
|
||||
const result = await tool.execute({ path: "foo.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.lines).toBe(6)
|
||||
|
||||
const filePath = path.join(tempDir, "foo.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe(content)
|
||||
})
|
||||
|
||||
it("should handle special characters in content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "const emoji = '🚀'\nconst quote = \"hello 'world'\""
|
||||
const result = await tool.execute({ path: "special.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const filePath = path.join(tempDir, "special.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
DeleteFileTool,
|
||||
type DeleteFileResult,
|
||||
} from "../../../../../src/infrastructure/tools/edit/DeleteFileTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
|
||||
function createMockStorage(fileData: { lines: string[] } | null = null): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(fileData),
|
||||
setFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
getAllFiles: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn().mockResolvedValue(undefined),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn().mockResolvedValue(undefined),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult = true,
|
||||
projectRoot = "/test/project",
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot,
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("DeleteFileTool", () => {
|
||||
let tool: DeleteFileTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new DeleteFileTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("delete_file")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("edit")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(1)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description mentioning confirmation", () => {
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(tool.validateParams({ path: "src/file.ts" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({})).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
expect(tool.validateParams({ path: " " })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delete-file-test-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should delete existing file", async () => {
|
||||
const testFile = path.join(tempDir, "to-delete.ts")
|
||||
await fs.writeFile(testFile, "content to delete", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["content to delete"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as DeleteFileResult
|
||||
expect(data.path).toBe("to-delete.ts")
|
||||
expect(data.deleted).toBe(true)
|
||||
|
||||
await expect(fs.access(testFile)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should delete file from storage", async () => {
|
||||
const testFile = path.join(tempDir, "to-delete.ts")
|
||||
await fs.writeFile(testFile, "content", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["content"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||
|
||||
expect(storage.deleteFile).toHaveBeenCalledWith("to-delete.ts")
|
||||
expect(storage.deleteAST).toHaveBeenCalledWith("to-delete.ts")
|
||||
expect(storage.deleteMeta).toHaveBeenCalledWith("to-delete.ts")
|
||||
})
|
||||
|
||||
it("should call requestConfirmation with diff info", async () => {
|
||||
const testFile = path.join(tempDir, "to-delete.ts")
|
||||
await fs.writeFile(testFile, "line 1\nline 2", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["line 1", "line 2"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Delete file: to-delete.ts", {
|
||||
filePath: "to-delete.ts",
|
||||
oldLines: ["line 1", "line 2"],
|
||||
newLines: [],
|
||||
startLine: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("should cancel deletion when confirmation rejected", async () => {
|
||||
const testFile = path.join(tempDir, "keep.ts")
|
||||
await fs.writeFile(testFile, "keep this", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["keep this"] })
|
||||
const ctx = createMockContext(storage, false, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "keep.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File deletion cancelled by user")
|
||||
|
||||
const content = await fs.readFile(testFile, "utf-8")
|
||||
expect(content).toBe("keep this")
|
||||
})
|
||||
|
||||
it("should return error for path outside project root", async () => {
|
||||
const ctx = createMockContext(undefined, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
})
|
||||
|
||||
it("should return error if file does not exist", async () => {
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File not found: nonexistent.ts")
|
||||
})
|
||||
|
||||
it("should read content from filesystem if not in storage", async () => {
|
||||
const testFile = path.join(tempDir, "not-indexed.ts")
|
||||
await fs.writeFile(testFile, "filesystem content\nline 2", "utf-8")
|
||||
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "not-indexed.ts" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
|
||||
"Delete file: not-indexed.ts",
|
||||
expect.objectContaining({
|
||||
oldLines: ["filesystem content", "line 2"],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const testFile = path.join(tempDir, "file.ts")
|
||||
await fs.writeFile(testFile, "x", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["x"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^delete_file-\d+$/)
|
||||
})
|
||||
|
||||
it("should include executionTimeMs in result", async () => {
|
||||
const testFile = path.join(tempDir, "file.ts")
|
||||
await fs.writeFile(testFile, "x", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["x"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should not delete directories", async () => {
|
||||
const dirPath = path.join(tempDir, "some-dir")
|
||||
await fs.mkdir(dirPath)
|
||||
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "some-dir" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File not found: some-dir")
|
||||
})
|
||||
|
||||
it("should handle nested file paths", async () => {
|
||||
const nestedDir = path.join(tempDir, "a/b/c")
|
||||
await fs.mkdir(nestedDir, { recursive: true })
|
||||
const testFile = path.join(nestedDir, "file.ts")
|
||||
await fs.writeFile(testFile, "nested", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["nested"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "a/b/c/file.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
await expect(fs.access(testFile)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,493 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
EditLinesTool,
|
||||
type EditLinesResult,
|
||||
} from "../../../../../src/infrastructure/tools/edit/EditLinesTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import { hashLines } from "../../../../../src/shared/utils/hash.js"
|
||||
|
||||
function createMockStorage(fileData: { lines: string[]; hash: string } | null = null): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(fileData),
|
||||
setFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult = true,
|
||||
projectRoot = "/test/project",
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot,
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("EditLinesTool", () => {
|
||||
let tool: EditLinesTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new EditLinesTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("edit_lines")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("edit")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(4)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("start")
|
||||
expect(tool.parameters[1].required).toBe(true)
|
||||
expect(tool.parameters[2].name).toBe("end")
|
||||
expect(tool.parameters[2].required).toBe(true)
|
||||
expect(tool.parameters[3].name).toBe("content")
|
||||
expect(tool.parameters[3].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description mentioning confirmation", () => {
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({
|
||||
path: "src/index.ts",
|
||||
start: 1,
|
||||
end: 5,
|
||||
content: "new content",
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({ start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "", start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
expect(tool.validateParams({ path: " ", start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123, start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing start", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-integer start", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1.5, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' is required and must be an integer",
|
||||
)
|
||||
expect(tool.validateParams({ path: "test.ts", start: "1", end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for start < 1", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 0, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' must be >= 1",
|
||||
)
|
||||
expect(tool.validateParams({ path: "test.ts", start: -1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' must be >= 1",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing end", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, content: "x" })).toBe(
|
||||
"Parameter 'end' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-integer end", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5.5, content: "x" })).toBe(
|
||||
"Parameter 'end' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for end < 1", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 0, content: "x" })).toBe(
|
||||
"Parameter 'end' must be >= 1",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for start > end", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 10, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' must be <= 'end'",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5 })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5, content: 123 })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow empty content string", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "test.ts", start: 1, end: 5, content: "" }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
let tempDir: string
|
||||
let testFilePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-lines-test-"))
|
||||
testFilePath = path.join(tempDir, "test.ts")
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should replace lines with new content", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
|
||||
const originalContent = originalLines.join("\n")
|
||||
await fs.writeFile(testFilePath, originalContent, "utf-8")
|
||||
|
||||
const lines = [...originalLines]
|
||||
const hash = hashLines(lines)
|
||||
const storage = createMockStorage({ lines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 4, content: "new line A\nnew line B" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.path).toBe("test.ts")
|
||||
expect(data.startLine).toBe(2)
|
||||
expect(data.endLine).toBe(4)
|
||||
expect(data.linesReplaced).toBe(3)
|
||||
expect(data.linesInserted).toBe(2)
|
||||
expect(data.totalLines).toBe(4)
|
||||
|
||||
const newContent = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(newContent).toBe("line 1\nnew line A\nnew line B\nline 5")
|
||||
})
|
||||
|
||||
it("should call requestConfirmation with diff info", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "test.ts", start: 2, end: 2, content: "replaced" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Replace lines 2-2 in test.ts", {
|
||||
filePath: "test.ts",
|
||||
oldLines: ["line 2"],
|
||||
newLines: ["replaced"],
|
||||
startLine: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("should cancel edit when confirmation rejected", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
const originalContent = originalLines.join("\n")
|
||||
await fs.writeFile(testFilePath, originalContent, "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, false, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "changed" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Edit cancelled by user")
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(content).toBe(originalContent)
|
||||
})
|
||||
|
||||
it("should update storage after edit", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "test.ts", start: 1, end: 1, content: "changed" }, ctx)
|
||||
|
||||
expect(storage.setFile).toHaveBeenCalledWith(
|
||||
"test.ts",
|
||||
expect.objectContaining({
|
||||
lines: ["changed", "line 2"],
|
||||
hash: hashLines(["changed", "line 2"]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for path outside project root", async () => {
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "../outside/file.ts", start: 1, end: 1, content: "x" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
})
|
||||
|
||||
it("should return error when start exceeds file length", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 10, end: 15, content: "x" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Start line 10 exceeds file length (2 lines)")
|
||||
})
|
||||
|
||||
it("should adjust end to file length if it exceeds", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 100, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.endLine).toBe(3)
|
||||
expect(data.linesReplaced).toBe(2)
|
||||
})
|
||||
|
||||
it("should detect hash conflict", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const oldHash = hashLines(["old content"])
|
||||
const storage = createMockStorage({ lines: originalLines, hash: oldHash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
"File has been modified externally. Please refresh the file before editing.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow edit when file not in storage", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle single line replacement", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 2, content: "replaced line 2" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const content = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(content).toBe("line 1\nreplaced line 2\nline 3")
|
||||
})
|
||||
|
||||
it("should handle replacing all lines", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 2, content: "completely\nnew\nfile" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const content = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(content).toBe("completely\nnew\nfile")
|
||||
})
|
||||
|
||||
it("should handle inserting more lines than replaced", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "a\nb\nc\nd" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.linesReplaced).toBe(1)
|
||||
expect(data.linesInserted).toBe(4)
|
||||
expect(data.totalLines).toBe(5)
|
||||
})
|
||||
|
||||
it("should handle deleting lines (empty content)", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 2, content: "" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.linesReplaced).toBe(1)
|
||||
expect(data.linesInserted).toBe(1)
|
||||
expect(data.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const originalLines = ["line 1"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.callId).toMatch(/^edit_lines-\d+$/)
|
||||
})
|
||||
|
||||
it("should include executionTimeMs in result", async () => {
|
||||
const originalLines = ["line 1"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should return error when file not found", async () => {
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "nonexistent.ts", start: 1, end: 1, content: "x" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("ENOENT")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GitCommitTool,
|
||||
type GitCommitResult,
|
||||
} from "../../../../../src/infrastructure/tools/git/GitCommitTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { SimpleGit, CommitResult, StatusResult } from "simple-git"
|
||||
|
||||
function createMockStorage(): IStorage {
|
||||
return {
|
||||
getFile: vi.fn(),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult: boolean = true,
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStatusResult(
|
||||
overrides: Partial<StatusResult> = {},
|
||||
): StatusResult {
|
||||
return {
|
||||
not_added: [],
|
||||
conflicted: [],
|
||||
created: [],
|
||||
deleted: [],
|
||||
ignored: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
files: [],
|
||||
staged: ["file.ts"],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
current: "main",
|
||||
tracking: "origin/main",
|
||||
detached: false,
|
||||
isClean: () => false,
|
||||
...overrides,
|
||||
} as StatusResult
|
||||
}
|
||||
|
||||
function createMockCommitResult(
|
||||
overrides: Partial<CommitResult> = {},
|
||||
): CommitResult {
|
||||
return {
|
||||
commit: "abc1234",
|
||||
branch: "main",
|
||||
root: false,
|
||||
author: null,
|
||||
summary: {
|
||||
changes: 1,
|
||||
insertions: 5,
|
||||
deletions: 2,
|
||||
},
|
||||
...overrides,
|
||||
} as CommitResult
|
||||
}
|
||||
|
||||
function createMockGit(options: {
|
||||
isRepo?: boolean
|
||||
status?: StatusResult
|
||||
commitResult?: CommitResult
|
||||
error?: Error
|
||||
addError?: Error
|
||||
}): SimpleGit {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||
status: vi.fn().mockResolvedValue(
|
||||
options.status ?? createMockStatusResult(),
|
||||
),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
}
|
||||
|
||||
if (options.addError) {
|
||||
mockGit.add.mockRejectedValue(options.addError)
|
||||
} else {
|
||||
mockGit.add.mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
if (options.error) {
|
||||
mockGit.commit.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.commit.mockResolvedValue(
|
||||
options.commitResult ?? createMockCommitResult(),
|
||||
)
|
||||
}
|
||||
|
||||
return mockGit as unknown as SimpleGit
|
||||
}
|
||||
|
||||
describe("GitCommitTool", () => {
|
||||
let tool: GitCommitTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GitCommitTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("git_commit")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("git")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("message")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("files")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("commit")
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return error for missing message", () => {
|
||||
expect(tool.validateParams({})).toContain("message")
|
||||
expect(tool.validateParams({})).toContain("required")
|
||||
})
|
||||
|
||||
it("should return error for non-string message", () => {
|
||||
expect(tool.validateParams({ message: 123 })).toContain("message")
|
||||
expect(tool.validateParams({ message: 123 })).toContain("string")
|
||||
})
|
||||
|
||||
it("should return error for empty message", () => {
|
||||
expect(tool.validateParams({ message: "" })).toContain("empty")
|
||||
expect(tool.validateParams({ message: " " })).toContain("empty")
|
||||
})
|
||||
|
||||
it("should return null for valid message", () => {
|
||||
expect(tool.validateParams({ message: "fix: bug" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid message with files", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-array files", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: "a.ts" }),
|
||||
).toContain("array")
|
||||
})
|
||||
|
||||
it("should return error for non-string in files array", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: [1, 2] }),
|
||||
).toContain("strings")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
describe("not a git repository", () => {
|
||||
it("should return error when not in a git repo", async () => {
|
||||
const mockGit = createMockGit({ isRepo: false })
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Not a git repository")
|
||||
})
|
||||
})
|
||||
|
||||
describe("nothing to commit", () => {
|
||||
it("should return error when no staged files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ staged: [] }),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Nothing to commit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with staged files", () => {
|
||||
it("should commit successfully", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ staged: ["file.ts"] }),
|
||||
commitResult: createMockCommitResult({
|
||||
commit: "def5678",
|
||||
branch: "main",
|
||||
summary: { changes: 1, insertions: 10, deletions: 3 },
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "feat: new feature" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitCommitResult
|
||||
expect(data.hash).toBe("def5678")
|
||||
expect(data.branch).toBe("main")
|
||||
expect(data.message).toBe("feat: new feature")
|
||||
expect(data.filesChanged).toBe(1)
|
||||
expect(data.insertions).toBe(10)
|
||||
expect(data.deletions).toBe(3)
|
||||
})
|
||||
|
||||
it("should include author when available", async () => {
|
||||
const mockGit = createMockGit({
|
||||
commitResult: createMockCommitResult({
|
||||
author: {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitCommitResult
|
||||
expect(data.author).toEqual({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("files parameter", () => {
|
||||
it("should stage specified files before commit", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ staged: [] }),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute(
|
||||
{ message: "test", files: ["a.ts", "b.ts"] },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
|
||||
})
|
||||
|
||||
it("should not call add when files is empty", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute(
|
||||
{ message: "test", files: [] },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(mockGit.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle add errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
addError: new Error("Failed to add files"),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test", files: ["nonexistent.ts"] },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Failed to add files")
|
||||
})
|
||||
})
|
||||
|
||||
describe("confirmation", () => {
|
||||
it("should request confirmation before commit", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0] as string
|
||||
expect(confirmMessage).toContain("Committing")
|
||||
expect(confirmMessage).toContain("test commit")
|
||||
})
|
||||
|
||||
it("should cancel commit when user declines", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext(undefined, false)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("cancelled")
|
||||
expect(mockGit.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should proceed with commit when user confirms", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext(undefined, true)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle git command errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
error: new Error("Git commit failed"),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Git commit failed")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(true),
|
||||
status: vi.fn().mockResolvedValue(createMockStatusResult()),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn().mockRejectedValue("string error"),
|
||||
} as unknown as SimpleGit
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timing", () => {
|
||||
it("should return timing information", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.callId).toMatch(/^git_commit-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GitDiffTool,
|
||||
type GitDiffResult,
|
||||
} from "../../../../../src/infrastructure/tools/git/GitDiffTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { SimpleGit, DiffResult } from "simple-git"
|
||||
|
||||
function createMockStorage(): IStorage {
|
||||
return {
|
||||
getFile: vi.fn(),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockDiffSummary(overrides: Partial<DiffResult> = {}): DiffResult {
|
||||
return {
|
||||
changed: 0,
|
||||
deletions: 0,
|
||||
insertions: 0,
|
||||
files: [],
|
||||
...overrides,
|
||||
} as DiffResult
|
||||
}
|
||||
|
||||
function createMockGit(options: {
|
||||
isRepo?: boolean
|
||||
diffSummary?: DiffResult
|
||||
diff?: string
|
||||
error?: Error
|
||||
}): SimpleGit {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||
diffSummary: vi.fn(),
|
||||
diff: vi.fn(),
|
||||
}
|
||||
|
||||
if (options.error) {
|
||||
mockGit.diffSummary.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.diffSummary.mockResolvedValue(
|
||||
options.diffSummary ?? createMockDiffSummary(),
|
||||
)
|
||||
mockGit.diff.mockResolvedValue(options.diff ?? "")
|
||||
}
|
||||
|
||||
return mockGit as unknown as SimpleGit
|
||||
}
|
||||
|
||||
describe("GitDiffTool", () => {
|
||||
let tool: GitDiffTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GitDiffTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("git_diff")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("git")
|
||||
})
|
||||
|
||||
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("staged")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("diff")
|
||||
expect(tool.description).toContain("changes")
|
||||
})
|
||||
})
|
||||
|
||||
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 staged", () => {
|
||||
expect(tool.validateParams({ staged: true })).toBeNull()
|
||||
expect(tool.validateParams({ staged: false })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for invalid path type", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toContain("path")
|
||||
expect(tool.validateParams({ path: 123 })).toContain("string")
|
||||
})
|
||||
|
||||
it("should return error for invalid staged type", () => {
|
||||
expect(tool.validateParams({ staged: "yes" })).toContain("staged")
|
||||
expect(tool.validateParams({ staged: "yes" })).toContain("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
describe("not a git repository", () => {
|
||||
it("should return error when not in a git repo", async () => {
|
||||
const mockGit = createMockGit({ isRepo: false })
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Not a git repository")
|
||||
})
|
||||
})
|
||||
|
||||
describe("no changes", () => {
|
||||
it("should return empty diff for clean repo", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({ files: [] }),
|
||||
diff: "",
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.hasChanges).toBe(false)
|
||||
expect(data.files).toHaveLength(0)
|
||||
expect(data.diff).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with changes", () => {
|
||||
it("should return diff for modified files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "src/index.ts", insertions: 5, deletions: 2, binary: false },
|
||||
],
|
||||
insertions: 5,
|
||||
deletions: 2,
|
||||
}),
|
||||
diff: "diff --git a/src/index.ts",
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.hasChanges).toBe(true)
|
||||
expect(data.files).toHaveLength(1)
|
||||
expect(data.files[0].file).toBe("src/index.ts")
|
||||
expect(data.files[0].insertions).toBe(5)
|
||||
expect(data.files[0].deletions).toBe(2)
|
||||
})
|
||||
|
||||
it("should return multiple files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "a.ts", insertions: 1, deletions: 0, binary: false },
|
||||
{ file: "b.ts", insertions: 2, deletions: 1, binary: false },
|
||||
{ file: "c.ts", insertions: 0, deletions: 5, binary: false },
|
||||
],
|
||||
insertions: 3,
|
||||
deletions: 6,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.files).toHaveLength(3)
|
||||
expect(data.summary.filesChanged).toBe(3)
|
||||
expect(data.summary.insertions).toBe(3)
|
||||
expect(data.summary.deletions).toBe(6)
|
||||
})
|
||||
|
||||
it("should handle binary files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "image.png", insertions: 0, deletions: 0, binary: true },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.files[0].binary).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("staged parameter", () => {
|
||||
it("should default to false (unstaged)", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.staged).toBe(false)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it("should pass --cached for staged=true", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ staged: true }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.staged).toBe(true)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("path parameter", () => {
|
||||
it("should filter by path", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ path: "src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.pathFilter).toBe("src")
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--", "src"])
|
||||
})
|
||||
|
||||
it("should combine staged and path", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ staged: true, path: "src/index.ts" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith([
|
||||
"--cached",
|
||||
"--",
|
||||
"src/index.ts",
|
||||
])
|
||||
})
|
||||
|
||||
it("should return null pathFilter when not provided", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.pathFilter).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("diff text", () => {
|
||||
it("should include full diff text", async () => {
|
||||
const diffText = `diff --git a/src/index.ts b/src/index.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
+import { foo } from "./foo"
|
||||
export function main() {
|
||||
console.log("hello")
|
||||
}`
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "src/index.ts", insertions: 1, deletions: 0, binary: false },
|
||||
],
|
||||
}),
|
||||
diff: diffText,
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.diff).toBe(diffText)
|
||||
expect(data.diff).toContain("diff --git")
|
||||
expect(data.diff).toContain("import { foo }")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle git command errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
error: new Error("Git command failed"),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Git command failed")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(true),
|
||||
diffSummary: vi.fn().mockRejectedValue("string error"),
|
||||
} as unknown as SimpleGit
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timing", () => {
|
||||
it("should return timing information", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^git_diff-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,503 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GitStatusTool,
|
||||
type GitStatusResult,
|
||||
} from "../../../../../src/infrastructure/tools/git/GitStatusTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { SimpleGit, StatusResult } from "simple-git"
|
||||
|
||||
function createMockStorage(): IStorage {
|
||||
return {
|
||||
getFile: vi.fn(),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
|
||||
return {
|
||||
not_added: [],
|
||||
conflicted: [],
|
||||
created: [],
|
||||
deleted: [],
|
||||
ignored: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
files: [],
|
||||
staged: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
current: "main",
|
||||
tracking: "origin/main",
|
||||
detached: false,
|
||||
isClean: () => true,
|
||||
...overrides,
|
||||
} as StatusResult
|
||||
}
|
||||
|
||||
function createMockGit(options: {
|
||||
isRepo?: boolean
|
||||
status?: StatusResult
|
||||
error?: Error
|
||||
}): SimpleGit {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||
status: vi.fn(),
|
||||
}
|
||||
|
||||
if (options.error) {
|
||||
mockGit.status.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.status.mockResolvedValue(options.status ?? createMockStatusResult())
|
||||
}
|
||||
|
||||
return mockGit as unknown as SimpleGit
|
||||
}
|
||||
|
||||
describe("GitStatusTool", () => {
|
||||
let tool: GitStatusTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GitStatusTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("git_status")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("git")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have no parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("git")
|
||||
expect(tool.description).toContain("status")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for empty params", () => {
|
||||
expect(tool.validateParams({})).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for any params (no required)", () => {
|
||||
expect(tool.validateParams({ foo: "bar" })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
describe("not a git repository", () => {
|
||||
it("should return error when not in a git repo", async () => {
|
||||
const mockGit = createMockGit({ isRepo: false })
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Not a git repository")
|
||||
})
|
||||
})
|
||||
|
||||
describe("clean repository", () => {
|
||||
it("should return clean status", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
current: "main",
|
||||
tracking: "origin/main",
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
isClean: () => true,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.branch).toBe("main")
|
||||
expect(data.tracking).toBe("origin/main")
|
||||
expect(data.isClean).toBe(true)
|
||||
expect(data.staged).toHaveLength(0)
|
||||
expect(data.modified).toHaveLength(0)
|
||||
expect(data.untracked).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("branch information", () => {
|
||||
it("should return current branch name", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ current: "feature/test" }),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.branch).toBe("feature/test")
|
||||
})
|
||||
|
||||
it("should handle detached HEAD", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ current: null }),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.branch).toBe("HEAD (detached)")
|
||||
})
|
||||
|
||||
it("should return tracking branch when available", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ tracking: "origin/develop" }),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.tracking).toBe("origin/develop")
|
||||
})
|
||||
|
||||
it("should handle no tracking branch", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ tracking: null }),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.tracking).toBeNull()
|
||||
})
|
||||
|
||||
it("should return ahead/behind counts", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ ahead: 3, behind: 1 }),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.ahead).toBe(3)
|
||||
expect(data.behind).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("staged files", () => {
|
||||
it("should return staged files (new file)", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "new.ts", index: "A", working_dir: " " }],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.staged).toHaveLength(1)
|
||||
expect(data.staged[0].path).toBe("new.ts")
|
||||
expect(data.staged[0].index).toBe("A")
|
||||
})
|
||||
|
||||
it("should return staged files (modified)", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "src/index.ts", index: "M", working_dir: " " }],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.staged).toHaveLength(1)
|
||||
expect(data.staged[0].path).toBe("src/index.ts")
|
||||
expect(data.staged[0].index).toBe("M")
|
||||
})
|
||||
|
||||
it("should return staged files (deleted)", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "old.ts", index: "D", working_dir: " " }],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.staged).toHaveLength(1)
|
||||
expect(data.staged[0].index).toBe("D")
|
||||
})
|
||||
|
||||
it("should return multiple staged files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [
|
||||
{ path: "a.ts", index: "A", working_dir: " " },
|
||||
{ path: "b.ts", index: "M", working_dir: " " },
|
||||
{ path: "c.ts", index: "D", working_dir: " " },
|
||||
],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.staged).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("modified files", () => {
|
||||
it("should return modified unstaged files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "src/app.ts", index: " ", working_dir: "M" }],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.modified).toHaveLength(1)
|
||||
expect(data.modified[0].path).toBe("src/app.ts")
|
||||
expect(data.modified[0].workingDir).toBe("M")
|
||||
})
|
||||
|
||||
it("should return deleted unstaged files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "deleted.ts", index: " ", working_dir: "D" }],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.modified).toHaveLength(1)
|
||||
expect(data.modified[0].workingDir).toBe("D")
|
||||
})
|
||||
})
|
||||
|
||||
describe("untracked files", () => {
|
||||
it("should return untracked files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
not_added: ["new-file.ts", "another.js"],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.untracked).toContain("new-file.ts")
|
||||
expect(data.untracked).toContain("another.js")
|
||||
})
|
||||
})
|
||||
|
||||
describe("conflicted files", () => {
|
||||
it("should return conflicted files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
conflicted: ["conflict.ts"],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.conflicted).toContain("conflict.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mixed status", () => {
|
||||
it("should correctly categorize files with both staged and unstaged changes", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "both.ts", index: "M", working_dir: "M" }],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.staged).toHaveLength(1)
|
||||
expect(data.modified).toHaveLength(1)
|
||||
expect(data.staged[0].path).toBe("both.ts")
|
||||
expect(data.modified[0].path).toBe("both.ts")
|
||||
})
|
||||
|
||||
it("should not include untracked in staged/modified", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({
|
||||
files: [{ path: "new.ts", index: "?", working_dir: "?" }],
|
||||
not_added: ["new.ts"],
|
||||
isClean: () => false,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitStatusResult
|
||||
expect(data.staged).toHaveLength(0)
|
||||
expect(data.modified).toHaveLength(0)
|
||||
expect(data.untracked).toContain("new.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle git command errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
error: new Error("Git command failed"),
|
||||
})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Git command failed")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(true),
|
||||
status: vi.fn().mockRejectedValue("string error"),
|
||||
} as unknown as SimpleGit
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timing", () => {
|
||||
it("should return timing information", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should include timing on error", async () => {
|
||||
const mockGit = createMockGit({ error: new Error("fail") })
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitStatusTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^git_status-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,368 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import {
|
||||
CommandSecurity,
|
||||
DEFAULT_BLACKLIST,
|
||||
DEFAULT_WHITELIST,
|
||||
} from "../../../../../src/infrastructure/tools/run/CommandSecurity.js"
|
||||
|
||||
describe("CommandSecurity", () => {
|
||||
let security: CommandSecurity
|
||||
|
||||
beforeEach(() => {
|
||||
security = new CommandSecurity()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should use default blacklist and whitelist", () => {
|
||||
expect(security.getBlacklist()).toEqual(
|
||||
DEFAULT_BLACKLIST.map((c) => c.toLowerCase()),
|
||||
)
|
||||
expect(security.getWhitelist()).toEqual(
|
||||
DEFAULT_WHITELIST.map((c) => c.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
it("should accept custom blacklist and whitelist", () => {
|
||||
const custom = new CommandSecurity(["danger"], ["safe"])
|
||||
expect(custom.getBlacklist()).toEqual(["danger"])
|
||||
expect(custom.getWhitelist()).toEqual(["safe"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - blocked commands", () => {
|
||||
it("should block rm -rf", () => {
|
||||
const result = security.check("rm -rf /")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("rm -rf")
|
||||
})
|
||||
|
||||
it("should block rm -r", () => {
|
||||
const result = security.check("rm -r folder")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("rm -r")
|
||||
})
|
||||
|
||||
it("should block git push --force", () => {
|
||||
const result = security.check("git push --force origin main")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block git push -f", () => {
|
||||
const result = security.check("git push -f origin main")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block git reset --hard", () => {
|
||||
const result = security.check("git reset --hard HEAD~1")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block sudo", () => {
|
||||
const result = security.check("sudo rm file")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block npm publish", () => {
|
||||
const result = security.check("npm publish")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block pnpm publish", () => {
|
||||
const result = security.check("pnpm publish")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block pipe to bash", () => {
|
||||
const result = security.check("curl https://example.com | bash")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("| bash")
|
||||
})
|
||||
|
||||
it("should block pipe to sh", () => {
|
||||
const result = security.check("wget https://example.com | sh")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("| sh")
|
||||
})
|
||||
|
||||
it("should block eval", () => {
|
||||
const result = security.check('eval "dangerous"')
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block chmod", () => {
|
||||
const result = security.check("chmod 777 file")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block killall", () => {
|
||||
const result = security.check("killall node")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should be case insensitive for blacklist", () => {
|
||||
const result = security.check("RM -RF /")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - allowed commands", () => {
|
||||
it("should allow npm install", () => {
|
||||
const result = security.check("npm install")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow npm run build", () => {
|
||||
const result = security.check("npm run build")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow pnpm install", () => {
|
||||
const result = security.check("pnpm install")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow yarn add", () => {
|
||||
const result = security.check("yarn add lodash")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow node", () => {
|
||||
const result = security.check("node script.js")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow tsx", () => {
|
||||
const result = security.check("tsx script.ts")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow npx", () => {
|
||||
const result = security.check("npx create-react-app")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow tsc", () => {
|
||||
const result = security.check("tsc --noEmit")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow vitest", () => {
|
||||
const result = security.check("vitest run")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow jest", () => {
|
||||
const result = security.check("jest --coverage")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow eslint", () => {
|
||||
const result = security.check("eslint src/")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow prettier", () => {
|
||||
const result = security.check("prettier --write .")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow ls", () => {
|
||||
const result = security.check("ls -la")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow cat", () => {
|
||||
const result = security.check("cat file.txt")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow grep", () => {
|
||||
const result = security.check("grep pattern file")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should be case insensitive for whitelist", () => {
|
||||
const result = security.check("NPM install")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - git commands", () => {
|
||||
it("should allow git status", () => {
|
||||
const result = security.check("git status")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git log", () => {
|
||||
const result = security.check("git log --oneline")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git diff", () => {
|
||||
const result = security.check("git diff HEAD~1")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git branch", () => {
|
||||
const result = security.check("git branch -a")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git fetch", () => {
|
||||
const result = security.check("git fetch origin")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git pull", () => {
|
||||
const result = security.check("git pull origin main")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git stash", () => {
|
||||
const result = security.check("git stash")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should require confirmation for git commit", () => {
|
||||
const result = security.check("git commit -m 'message'")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git push (without force)", () => {
|
||||
const result = security.check("git push origin main")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git checkout", () => {
|
||||
const result = security.check("git checkout -b new-branch")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git merge", () => {
|
||||
const result = security.check("git merge feature")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git rebase", () => {
|
||||
const result = security.check("git rebase main")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git without subcommand", () => {
|
||||
const result = security.check("git")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - requires confirmation", () => {
|
||||
it("should require confirmation for unknown commands", () => {
|
||||
const result = security.check("unknown-command")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
expect(result.reason).toContain("not in the whitelist")
|
||||
})
|
||||
|
||||
it("should require confirmation for curl (without pipe)", () => {
|
||||
const result = security.check("curl https://example.com")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for wget (without pipe)", () => {
|
||||
const result = security.check("wget https://example.com")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for mkdir", () => {
|
||||
const result = security.check("mkdir new-folder")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for touch", () => {
|
||||
const result = security.check("touch new-file.txt")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for cp", () => {
|
||||
const result = security.check("cp file1 file2")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for mv", () => {
|
||||
const result = security.check("mv file1 file2")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addToBlacklist", () => {
|
||||
it("should add patterns to blacklist", () => {
|
||||
security.addToBlacklist(["danger"])
|
||||
expect(security.getBlacklist()).toContain("danger")
|
||||
})
|
||||
|
||||
it("should not add duplicates", () => {
|
||||
const initialLength = security.getBlacklist().length
|
||||
security.addToBlacklist(["rm -rf"])
|
||||
expect(security.getBlacklist().length).toBe(initialLength)
|
||||
})
|
||||
|
||||
it("should normalize to lowercase", () => {
|
||||
security.addToBlacklist(["DANGER"])
|
||||
expect(security.getBlacklist()).toContain("danger")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addToWhitelist", () => {
|
||||
it("should add commands to whitelist", () => {
|
||||
security.addToWhitelist(["mycommand"])
|
||||
expect(security.getWhitelist()).toContain("mycommand")
|
||||
})
|
||||
|
||||
it("should not add duplicates", () => {
|
||||
const initialLength = security.getWhitelist().length
|
||||
security.addToWhitelist(["npm"])
|
||||
expect(security.getWhitelist().length).toBe(initialLength)
|
||||
})
|
||||
|
||||
it("should normalize to lowercase", () => {
|
||||
security.addToWhitelist(["MYCOMMAND"])
|
||||
expect(security.getWhitelist()).toContain("mycommand")
|
||||
})
|
||||
|
||||
it("should allow newly added commands", () => {
|
||||
security.addToWhitelist(["mycommand"])
|
||||
const result = security.check("mycommand arg1 arg2")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty command", () => {
|
||||
const result = security.check("")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should handle whitespace-only command", () => {
|
||||
const result = security.check(" ")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should handle command with leading/trailing whitespace", () => {
|
||||
const result = security.check(" npm install ")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should handle command with multiple spaces", () => {
|
||||
const result = security.check("npm install lodash")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should detect blocked pattern anywhere in command", () => {
|
||||
const result = security.check("echo test && rm -rf /")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should detect blocked pattern in subshell", () => {
|
||||
const result = security.check("$(rm -rf /)")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,505 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
RunCommandTool,
|
||||
type RunCommandResult,
|
||||
} from "../../../../../src/infrastructure/tools/run/RunCommandTool.js"
|
||||
import { CommandSecurity } from "../../../../../src/infrastructure/tools/run/CommandSecurity.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().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult: boolean = true,
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string }
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ExecResult>
|
||||
|
||||
function createMockExec(options: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
error?: Error & { code?: number; stdout?: string; stderr?: string }
|
||||
}): ExecFn {
|
||||
return vi.fn().mockImplementation(() => {
|
||||
if (options.error) {
|
||||
return Promise.reject(options.error)
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: options.stdout ?? "",
|
||||
stderr: options.stderr ?? "",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("RunCommandTool", () => {
|
||||
let tool: RunCommandTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new RunCommandTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("run_command")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("run")
|
||||
})
|
||||
|
||||
it("should not require confirmation (handled internally)", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("command")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("timeout")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("shell command")
|
||||
expect(tool.description).toContain("security")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return error for missing command", () => {
|
||||
expect(tool.validateParams({})).toContain("command")
|
||||
expect(tool.validateParams({})).toContain("required")
|
||||
})
|
||||
|
||||
it("should return error for non-string command", () => {
|
||||
expect(tool.validateParams({ command: 123 })).toContain("string")
|
||||
})
|
||||
|
||||
it("should return error for empty command", () => {
|
||||
expect(tool.validateParams({ command: "" })).toContain("empty")
|
||||
expect(tool.validateParams({ command: " " })).toContain("empty")
|
||||
})
|
||||
|
||||
it("should return null for valid command", () => {
|
||||
expect(tool.validateParams({ command: "ls" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-number timeout", () => {
|
||||
expect(
|
||||
tool.validateParams({ command: "ls", timeout: "5000" }),
|
||||
).toContain("number")
|
||||
})
|
||||
|
||||
it("should return error for negative timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain(
|
||||
"positive",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for zero timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain(
|
||||
"positive",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for timeout > 10 minutes", () => {
|
||||
expect(
|
||||
tool.validateParams({ command: "ls", timeout: 600001 }),
|
||||
).toContain("600000")
|
||||
})
|
||||
|
||||
it("should return null for valid timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: 5000 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - blocked commands", () => {
|
||||
it("should block dangerous commands", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "rm -rf /" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
expect(execFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should block sudo commands", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "sudo apt-get" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
})
|
||||
|
||||
it("should block git push --force", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "git push --force" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - allowed commands", () => {
|
||||
it("should execute whitelisted commands without confirmation", async () => {
|
||||
const execFn = createMockExec({ stdout: "output" })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "npm install" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||
expect(execFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return stdout and stderr", async () => {
|
||||
const execFn = createMockExec({
|
||||
stdout: "standard output",
|
||||
stderr: "standard error",
|
||||
})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "npm run build" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.stdout).toBe("standard output")
|
||||
expect(data.stderr).toBe("standard error")
|
||||
expect(data.exitCode).toBe(0)
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should mark requiredConfirmation as false", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.requiredConfirmation).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - requires confirmation", () => {
|
||||
it("should request confirmation for unknown commands", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "unknown-command" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should execute after confirmation", async () => {
|
||||
const execFn = createMockExec({ stdout: "done" })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext(undefined, true)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-script" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.requiredConfirmation).toBe(true)
|
||||
expect(execFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should cancel when user declines", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext(undefined, false)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-script" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("cancelled")
|
||||
expect(execFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should require confirmation for git commit", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "git commit -m 'test'" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - error handling", () => {
|
||||
it("should handle command failure with exit code", async () => {
|
||||
const error = Object.assign(new Error("Command failed"), {
|
||||
code: 1,
|
||||
stdout: "partial output",
|
||||
stderr: "error message",
|
||||
})
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "npm test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.exitCode).toBe(1)
|
||||
expect(data.stdout).toBe("partial output")
|
||||
expect(data.stderr).toBe("error message")
|
||||
})
|
||||
|
||||
it("should handle timeout", async () => {
|
||||
const error = new Error("Command timed out")
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should handle ETIMEDOUT", async () => {
|
||||
const error = new Error("ETIMEDOUT")
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should handle generic errors", async () => {
|
||||
const error = new Error("Something went wrong")
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Something went wrong")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const execFn = vi.fn().mockRejectedValue("string error")
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - options", () => {
|
||||
it("should use default timeout", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ timeout: 30000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom timeout", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should execute in project root", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
ctx.projectRoot = "/my/project"
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ cwd: "/my/project" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should disable colors", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ FORCE_COLOR: "0" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - output truncation", () => {
|
||||
it("should truncate very long output", async () => {
|
||||
const longOutput = "x".repeat(200000)
|
||||
const execFn = createMockExec({ stdout: longOutput })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.stdout.length).toBeLessThan(longOutput.length)
|
||||
expect(data.stdout).toContain("truncated")
|
||||
})
|
||||
|
||||
it("should not truncate normal output", async () => {
|
||||
const normalOutput = "normal output"
|
||||
const execFn = createMockExec({ stdout: normalOutput })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.stdout).toBe(normalOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - timing", () => {
|
||||
it("should return execution time", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.durationMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should return execution time ms in result", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^run_command-\d+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSecurity", () => {
|
||||
it("should return security instance", () => {
|
||||
const security = new CommandSecurity()
|
||||
const toolWithSecurity = new RunCommandTool(security)
|
||||
|
||||
expect(toolWithSecurity.getSecurity()).toBe(security)
|
||||
})
|
||||
|
||||
it("should allow modifying security", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-safe arg" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,552 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
RunTestsTool,
|
||||
type RunTestsResult,
|
||||
type TestRunner,
|
||||
} from "../../../../../src/infrastructure/tools/run/RunTestsTool.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().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string }
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ExecResult>
|
||||
|
||||
function createMockExec(options: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
error?: Error & { code?: number; stdout?: string; stderr?: string }
|
||||
}): ExecFn {
|
||||
return vi.fn().mockImplementation(() => {
|
||||
if (options.error) {
|
||||
return Promise.reject(options.error)
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: options.stdout ?? "",
|
||||
stderr: options.stderr ?? "",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createMockFsAccess(existingFiles: string[]): typeof import("fs/promises").access {
|
||||
return vi.fn().mockImplementation((filePath: string) => {
|
||||
for (const file of existingFiles) {
|
||||
if (filePath.endsWith(file)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"))
|
||||
})
|
||||
}
|
||||
|
||||
function createMockFsReadFile(
|
||||
packageJson?: Record<string, unknown>,
|
||||
): typeof import("fs/promises").readFile {
|
||||
return vi.fn().mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("package.json") && packageJson) {
|
||||
return Promise.resolve(JSON.stringify(packageJson))
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"))
|
||||
})
|
||||
}
|
||||
|
||||
describe("RunTestsTool", () => {
|
||||
let tool: RunTestsTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new RunTestsTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("run_tests")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("run")
|
||||
})
|
||||
|
||||
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[1].name).toBe("filter")
|
||||
expect(tool.parameters[2].name).toBe("watch")
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("test")
|
||||
expect(tool.description).toContain("vitest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for empty params", () => {
|
||||
expect(tool.validateParams({})).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "src", filter: "login", watch: true }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for invalid path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toContain("path")
|
||||
})
|
||||
|
||||
it("should return error for invalid filter", () => {
|
||||
expect(tool.validateParams({ filter: 123 })).toContain("filter")
|
||||
})
|
||||
|
||||
it("should return error for invalid watch", () => {
|
||||
expect(tool.validateParams({ watch: "yes" })).toContain("watch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectTestRunner", () => {
|
||||
it("should detect vitest from config file", async () => {
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect vitest from .js config", async () => {
|
||||
const fsAccess = createMockFsAccess(["vitest.config.js"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect vitest from .mts config", async () => {
|
||||
const fsAccess = createMockFsAccess(["vitest.config.mts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect jest from config file", async () => {
|
||||
const fsAccess = createMockFsAccess(["jest.config.js"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("jest")
|
||||
})
|
||||
|
||||
it("should detect vitest from devDependencies", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
devDependencies: { vitest: "^1.0.0" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect jest from devDependencies", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
devDependencies: { jest: "^29.0.0" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("jest")
|
||||
})
|
||||
|
||||
it("should detect mocha from devDependencies", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
devDependencies: { mocha: "^10.0.0" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("mocha")
|
||||
})
|
||||
|
||||
it("should detect npm test script as fallback", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
scripts: { test: "node test.js" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("npm")
|
||||
})
|
||||
|
||||
it("should return null when no runner found", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildCommand", () => {
|
||||
describe("vitest", () => {
|
||||
it("should build basic vitest command", () => {
|
||||
const cmd = tool.buildCommand("vitest")
|
||||
expect(cmd).toBe("npx vitest run")
|
||||
})
|
||||
|
||||
it("should build vitest with path", () => {
|
||||
const cmd = tool.buildCommand("vitest", "src/tests")
|
||||
expect(cmd).toBe("npx vitest run src/tests")
|
||||
})
|
||||
|
||||
it("should build vitest with filter", () => {
|
||||
const cmd = tool.buildCommand("vitest", undefined, "login")
|
||||
expect(cmd).toBe('npx vitest run -t "login"')
|
||||
})
|
||||
|
||||
it("should build vitest with watch", () => {
|
||||
const cmd = tool.buildCommand("vitest", undefined, undefined, true)
|
||||
expect(cmd).toBe("npx vitest")
|
||||
})
|
||||
|
||||
it("should build vitest with all options", () => {
|
||||
const cmd = tool.buildCommand("vitest", "src", "login", true)
|
||||
expect(cmd).toBe('npx vitest src -t "login"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("jest", () => {
|
||||
it("should build basic jest command", () => {
|
||||
const cmd = tool.buildCommand("jest")
|
||||
expect(cmd).toBe("npx jest")
|
||||
})
|
||||
|
||||
it("should build jest with path", () => {
|
||||
const cmd = tool.buildCommand("jest", "src/tests")
|
||||
expect(cmd).toBe("npx jest src/tests")
|
||||
})
|
||||
|
||||
it("should build jest with filter", () => {
|
||||
const cmd = tool.buildCommand("jest", undefined, "login")
|
||||
expect(cmd).toBe('npx jest -t "login"')
|
||||
})
|
||||
|
||||
it("should build jest with watch", () => {
|
||||
const cmd = tool.buildCommand("jest", undefined, undefined, true)
|
||||
expect(cmd).toBe("npx jest --watch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mocha", () => {
|
||||
it("should build basic mocha command", () => {
|
||||
const cmd = tool.buildCommand("mocha")
|
||||
expect(cmd).toBe("npx mocha")
|
||||
})
|
||||
|
||||
it("should build mocha with path", () => {
|
||||
const cmd = tool.buildCommand("mocha", "test/")
|
||||
expect(cmd).toBe("npx mocha test/")
|
||||
})
|
||||
|
||||
it("should build mocha with filter", () => {
|
||||
const cmd = tool.buildCommand("mocha", undefined, "login")
|
||||
expect(cmd).toBe('npx mocha --grep "login"')
|
||||
})
|
||||
|
||||
it("should build mocha with watch", () => {
|
||||
const cmd = tool.buildCommand("mocha", undefined, undefined, true)
|
||||
expect(cmd).toBe("npx mocha --watch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("npm", () => {
|
||||
it("should build basic npm test command", () => {
|
||||
const cmd = tool.buildCommand("npm")
|
||||
expect(cmd).toBe("npm test")
|
||||
})
|
||||
|
||||
it("should build npm test with path", () => {
|
||||
const cmd = tool.buildCommand("npm", "src/tests")
|
||||
expect(cmd).toBe("npm test -- src/tests")
|
||||
})
|
||||
|
||||
it("should build npm test with filter", () => {
|
||||
const cmd = tool.buildCommand("npm", undefined, "login")
|
||||
expect(cmd).toBe('npm test -- "login"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
describe("no runner detected", () => {
|
||||
it("should return error when no runner found", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({})
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("No test runner detected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful tests", () => {
|
||||
it("should return success when tests pass", async () => {
|
||||
const execFn = createMockExec({
|
||||
stdout: "All tests passed",
|
||||
stderr: "",
|
||||
})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.passed).toBe(true)
|
||||
expect(data.exitCode).toBe(0)
|
||||
expect(data.runner).toBe("vitest")
|
||||
expect(data.stdout).toContain("All tests passed")
|
||||
})
|
||||
|
||||
it("should include command in result", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toBe("npx vitest run")
|
||||
})
|
||||
|
||||
it("should include duration in result", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.durationMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("failing tests", () => {
|
||||
it("should return success=true but passed=false for test failures", async () => {
|
||||
const error = Object.assign(new Error("Tests failed"), {
|
||||
code: 1,
|
||||
stdout: "1 test failed",
|
||||
stderr: "AssertionError",
|
||||
})
|
||||
const execFn = createMockExec({ error })
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.passed).toBe(false)
|
||||
expect(data.exitCode).toBe(1)
|
||||
expect(data.stdout).toContain("1 test failed")
|
||||
expect(data.stderr).toContain("AssertionError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with options", () => {
|
||||
it("should pass path to command", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({ path: "src/tests" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toContain("src/tests")
|
||||
})
|
||||
|
||||
it("should pass filter to command", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({ filter: "login" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toContain('-t "login"')
|
||||
})
|
||||
|
||||
it("should pass watch option", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({ watch: true }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toBe("npx vitest")
|
||||
expect(data.command).not.toContain("run")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle timeout", async () => {
|
||||
const error = new Error("Command timed out")
|
||||
const execFn = createMockExec({ error })
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should handle generic errors", async () => {
|
||||
const error = new Error("Something went wrong")
|
||||
const execFn = createMockExec({ error })
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Something went wrong")
|
||||
})
|
||||
})
|
||||
|
||||
describe("exec options", () => {
|
||||
it("should run in project root", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
ctx.projectRoot = "/my/project"
|
||||
|
||||
await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ cwd: "/my/project" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should set CI environment variable", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ CI: "true" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^run_tests-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,534 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
FindDefinitionTool,
|
||||
type FindDefinitionResult,
|
||||
} from "../../../../../src/infrastructure/tools/search/FindDefinitionTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type {
|
||||
IStorage,
|
||||
SymbolIndex,
|
||||
SymbolLocation,
|
||||
} from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { FileData } from "../../../../../src/domain/value-objects/FileData.js"
|
||||
|
||||
function createMockFileData(lines: string[]): FileData {
|
||||
return {
|
||||
lines,
|
||||
hash: "abc123",
|
||||
size: lines.join("\n").length,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStorage(
|
||||
files: Map<string, FileData> = new Map(),
|
||||
symbolIndex: SymbolIndex = new Map(),
|
||||
): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockImplementation((p: string) => Promise.resolve(files.get(p) ?? null)),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(files),
|
||||
getFileCount: vi.fn().mockResolvedValue(files.size),
|
||||
getAST: vi.fn().mockResolvedValue(null),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn().mockResolvedValue(null),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(symbolIndex),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("FindDefinitionTool", () => {
|
||||
let tool: FindDefinitionTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new FindDefinitionTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("find_definition")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("search")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(1)
|
||||
expect(tool.parameters[0].name).toBe("symbol")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("Find where a symbol is defined")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(tool.validateParams({ symbol: "myFunction" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing symbol", () => {
|
||||
expect(tool.validateParams({})).toBe(
|
||||
"Parameter 'symbol' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty symbol", () => {
|
||||
expect(tool.validateParams({ symbol: "" })).toBe(
|
||||
"Parameter 'symbol' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for whitespace-only symbol", () => {
|
||||
expect(tool.validateParams({ symbol: " " })).toBe(
|
||||
"Parameter 'symbol' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("should find function definition", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/utils.ts",
|
||||
createMockFileData([
|
||||
"// Utility functions",
|
||||
"export function myFunction() {",
|
||||
" return 42",
|
||||
"}",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["myFunction", [{ path: "src/utils.ts", line: 2, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "myFunction" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.symbol).toBe("myFunction")
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions).toHaveLength(1)
|
||||
expect(data.definitions[0].path).toBe("src/utils.ts")
|
||||
expect(data.definitions[0].line).toBe(2)
|
||||
expect(data.definitions[0].type).toBe("function")
|
||||
})
|
||||
|
||||
it("should find class definition", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/models.ts",
|
||||
createMockFileData([
|
||||
"export class User {",
|
||||
" constructor(public name: string) {}",
|
||||
"}",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["User", [{ path: "src/models.ts", line: 1, type: "class" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "User" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions[0].type).toBe("class")
|
||||
})
|
||||
|
||||
it("should find interface definition", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/types.ts",
|
||||
createMockFileData(["export interface Config {", " port: number", "}"]),
|
||||
],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["Config", [{ path: "src/types.ts", line: 1, type: "interface" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "Config" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions[0].type).toBe("interface")
|
||||
})
|
||||
|
||||
it("should find type alias definition", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["ID", [{ path: "src/types.ts", line: 1, type: "type" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "ID" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions[0].type).toBe("type")
|
||||
})
|
||||
|
||||
it("should find variable definition", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["DEFAULT_CONFIG", [{ path: "src/config.ts", line: 5, type: "variable" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "DEFAULT_CONFIG" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions[0].type).toBe("variable")
|
||||
})
|
||||
|
||||
it("should find multiple definitions (function overloads)", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
[
|
||||
"process",
|
||||
[
|
||||
{ path: "src/a.ts", line: 1, type: "function" as const },
|
||||
{ path: "src/b.ts", line: 5, type: "function" as const },
|
||||
],
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "process" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should return not found for unknown symbol", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map()
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "unknownSymbol" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(false)
|
||||
expect(data.definitions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should suggest similar symbols when not found", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["myFunction", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
|
||||
["myFunctionAsync", [{ path: "src/a.ts", line: 5, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "myFunc" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(false)
|
||||
expect(data.suggestions).toBeDefined()
|
||||
expect(data.suggestions).toContain("myFunction")
|
||||
})
|
||||
|
||||
it("should not include suggestions when exact match found", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["myFunction", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "myFunction" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.suggestions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include context lines", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/test.ts",
|
||||
createMockFileData([
|
||||
"// Line 1",
|
||||
"// Line 2",
|
||||
"export function myFunc() {",
|
||||
" return 1",
|
||||
"}",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["myFunc", [{ path: "src/test.ts", line: 3, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "myFunc" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
const context = data.definitions[0].context
|
||||
expect(context).toContain("// Line 1")
|
||||
expect(context).toContain("// Line 2")
|
||||
expect(context).toContain("export function myFunc()")
|
||||
expect(context).toContain("return 1")
|
||||
expect(context).toContain("}")
|
||||
})
|
||||
|
||||
it("should mark definition line in context", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["// before", "const foo = 1", "// after"])],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["foo", [{ path: "src/test.ts", line: 2, type: "variable" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
const context = data.definitions[0].context
|
||||
expect(context).toContain("> 2│const foo = 1")
|
||||
expect(context).toContain(" 1│// before")
|
||||
})
|
||||
|
||||
it("should handle context at file start", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const x = 1", "// after"])],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["x", [{ path: "src/test.ts", line: 1, type: "variable" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
const context = data.definitions[0].context
|
||||
expect(context).toContain("> 1│const x = 1")
|
||||
})
|
||||
|
||||
it("should handle context at file end", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["// before", "const x = 1"])],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["x", [{ path: "src/test.ts", line: 2, type: "variable" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
const context = data.definitions[0].context
|
||||
expect(context).toContain("> 2│const x = 1")
|
||||
})
|
||||
|
||||
it("should handle empty context when file not found", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["foo", [{ path: "src/nonexistent.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(true)
|
||||
expect(data.definitions[0].context).toBe("")
|
||||
})
|
||||
|
||||
it("should sort definitions by path then line", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
[
|
||||
"foo",
|
||||
[
|
||||
{ path: "src/b.ts", line: 10, type: "function" as const },
|
||||
{ path: "src/a.ts", line: 5, type: "function" as const },
|
||||
{ path: "src/b.ts", line: 1, type: "function" as const },
|
||||
],
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.definitions[0].path).toBe("src/a.ts")
|
||||
expect(data.definitions[1].path).toBe("src/b.ts")
|
||||
expect(data.definitions[1].line).toBe(1)
|
||||
expect(data.definitions[2].path).toBe("src/b.ts")
|
||||
expect(data.definitions[2].line).toBe(10)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["x", [{ path: "src/a.ts", line: 1, type: "variable" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^find_definition-\d+$/)
|
||||
})
|
||||
|
||||
it("should include execution time in result", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map()
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = createMockStorage()
|
||||
;(storage.getSymbolIndex as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Redis connection failed"),
|
||||
)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Redis connection failed")
|
||||
})
|
||||
|
||||
it("should trim symbol before searching", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["foo", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: " foo " }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.symbol).toBe("foo")
|
||||
expect(data.found).toBe(true)
|
||||
})
|
||||
|
||||
it("should suggest symbols with small edit distance", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["fetchData", [{ path: "src/a.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "fethcData" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(false)
|
||||
expect(data.suggestions).toContain("fetchData")
|
||||
})
|
||||
|
||||
it("should limit suggestions to 5", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["testA", [{ path: "a.ts", line: 1, type: "function" as const }]],
|
||||
["testB", [{ path: "b.ts", line: 1, type: "function" as const }]],
|
||||
["testC", [{ path: "c.ts", line: 1, type: "function" as const }]],
|
||||
["testD", [{ path: "d.ts", line: 1, type: "function" as const }]],
|
||||
["testE", [{ path: "e.ts", line: 1, type: "function" as const }]],
|
||||
["testF", [{ path: "f.ts", line: 1, type: "function" as const }]],
|
||||
["testG", [{ path: "g.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.suggestions).toBeDefined()
|
||||
expect(data.suggestions!.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it("should sort suggestions alphabetically", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["testC", [{ path: "c.ts", line: 1, type: "function" as const }]],
|
||||
["testA", [{ path: "a.ts", line: 1, type: "function" as const }]],
|
||||
["testB", [{ path: "b.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.suggestions).toEqual(["testA", "testB", "testC"])
|
||||
})
|
||||
|
||||
it("should not include suggestions when no similar symbols exist", async () => {
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["xyz", [{ path: "a.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(new Map(), symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "abc" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindDefinitionResult
|
||||
expect(data.found).toBe(false)
|
||||
expect(data.suggestions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,564 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
FindReferencesTool,
|
||||
type FindReferencesResult,
|
||||
} from "../../../../../src/infrastructure/tools/search/FindReferencesTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type {
|
||||
IStorage,
|
||||
SymbolIndex,
|
||||
SymbolLocation,
|
||||
} from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { FileData } from "../../../../../src/domain/value-objects/FileData.js"
|
||||
|
||||
function createMockFileData(lines: string[]): FileData {
|
||||
return {
|
||||
lines,
|
||||
hash: "abc123",
|
||||
size: lines.join("\n").length,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStorage(
|
||||
files: Map<string, FileData> = new Map(),
|
||||
symbolIndex: SymbolIndex = new Map(),
|
||||
): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockImplementation((p: string) => Promise.resolve(files.get(p) ?? null)),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(files),
|
||||
getFileCount: vi.fn().mockResolvedValue(files.size),
|
||||
getAST: vi.fn().mockResolvedValue(null),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn().mockResolvedValue(null),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(symbolIndex),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("FindReferencesTool", () => {
|
||||
let tool: FindReferencesTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new FindReferencesTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("find_references")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("search")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("symbol")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("path")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("Find all usages")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params with symbol only", () => {
|
||||
expect(tool.validateParams({ symbol: "myFunction" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid params with symbol and path", () => {
|
||||
expect(tool.validateParams({ symbol: "myFunction", path: "src/" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing symbol", () => {
|
||||
expect(tool.validateParams({})).toBe(
|
||||
"Parameter 'symbol' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty symbol", () => {
|
||||
expect(tool.validateParams({ symbol: "" })).toBe(
|
||||
"Parameter 'symbol' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for whitespace-only symbol", () => {
|
||||
expect(tool.validateParams({ symbol: " " })).toBe(
|
||||
"Parameter 'symbol' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ symbol: "test", path: 123 })).toBe(
|
||||
"Parameter 'path' must be a string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("should find simple symbol references", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/index.ts",
|
||||
createMockFileData([
|
||||
"import { myFunction } from './utils'",
|
||||
"",
|
||||
"myFunction()",
|
||||
"const result = myFunction(42)",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "myFunction" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.symbol).toBe("myFunction")
|
||||
expect(data.totalReferences).toBe(3)
|
||||
expect(data.files).toBe(1)
|
||||
expect(data.references).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should find references across multiple files", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["const foo = 1", "console.log(foo)"])],
|
||||
[
|
||||
"src/b.ts",
|
||||
createMockFileData(["import { foo } from './a'", "export const bar = foo + 1"]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(4)
|
||||
expect(data.files).toBe(2)
|
||||
})
|
||||
|
||||
it("should include definition locations from symbol index", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/utils.ts", createMockFileData(["export function helper() {}", "helper()"])],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["helper", [{ path: "src/utils.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "helper" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.definitionLocations).toHaveLength(1)
|
||||
expect(data.definitionLocations[0]).toEqual({
|
||||
path: "src/utils.ts",
|
||||
line: 1,
|
||||
type: "function",
|
||||
})
|
||||
})
|
||||
|
||||
it("should mark definition lines", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/utils.ts", createMockFileData(["export function myFunc() {}", "myFunc()"])],
|
||||
])
|
||||
const symbolIndex: SymbolIndex = new Map([
|
||||
["myFunc", [{ path: "src/utils.ts", line: 1, type: "function" as const }]],
|
||||
])
|
||||
const storage = createMockStorage(files, symbolIndex)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "myFunc" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.references[0].isDefinition).toBe(true)
|
||||
expect(data.references[1].isDefinition).toBe(false)
|
||||
})
|
||||
|
||||
it("should filter by path", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["const x = 1"])],
|
||||
["src/b.ts", createMockFileData(["const x = 2"])],
|
||||
["lib/c.ts", createMockFileData(["const x = 3"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x", path: "src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(2)
|
||||
expect(data.references.every((r) => r.path.startsWith("src/"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should filter by specific file path", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["const x = 1"])],
|
||||
["src/b.ts", createMockFileData(["const x = 2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x", path: "src/a.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(1)
|
||||
expect(data.references[0].path).toBe("src/a.ts")
|
||||
})
|
||||
|
||||
it("should return empty result when no files match filter", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["const x = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x", path: "nonexistent" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(0)
|
||||
expect(data.files).toBe(0)
|
||||
})
|
||||
|
||||
it("should return empty result when symbol not found", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/a.ts", createMockFileData(["const foo = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "bar" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(0)
|
||||
expect(data.files).toBe(0)
|
||||
})
|
||||
|
||||
it("should use word boundaries for matching", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/test.ts",
|
||||
createMockFileData([
|
||||
"const foo = 1",
|
||||
"const foobar = 2",
|
||||
"const barfoo = 3",
|
||||
"const xfoox = 4",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(1)
|
||||
expect(data.references[0].line).toBe(1)
|
||||
})
|
||||
|
||||
it("should include column number", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const value = 1", " value = 2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "value" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.references[0].column).toBe(7)
|
||||
expect(data.references[1].column).toBe(5)
|
||||
})
|
||||
|
||||
it("should include context lines", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["// comment", "const foo = 1", "// after"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
const context = data.references[0].context
|
||||
expect(context).toContain("// comment")
|
||||
expect(context).toContain("const foo = 1")
|
||||
expect(context).toContain("// after")
|
||||
})
|
||||
|
||||
it("should mark current line in context", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["line1", "const foo = 1", "line3"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
const context = data.references[0].context
|
||||
expect(context).toContain("> 2│const foo = 1")
|
||||
expect(context).toContain(" 1│line1")
|
||||
})
|
||||
|
||||
it("should handle context at file start", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const foo = 1", "line2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
const context = data.references[0].context
|
||||
expect(context).toContain("> 1│const foo = 1")
|
||||
expect(context).toContain(" 2│line2")
|
||||
})
|
||||
|
||||
it("should handle context at file end", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["line1", "const foo = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
const context = data.references[0].context
|
||||
expect(context).toContain(" 1│line1")
|
||||
expect(context).toContain("> 2│const foo = 1")
|
||||
})
|
||||
|
||||
it("should find multiple occurrences on same line", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const x = x + x"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(3)
|
||||
expect(data.references[0].column).toBe(7)
|
||||
expect(data.references[1].column).toBe(11)
|
||||
expect(data.references[2].column).toBe(15)
|
||||
})
|
||||
|
||||
it("should sort results by path then line", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/b.ts", createMockFileData(["x", "", "x"])],
|
||||
["src/a.ts", createMockFileData(["x"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.references[0].path).toBe("src/a.ts")
|
||||
expect(data.references[1].path).toBe("src/b.ts")
|
||||
expect(data.references[1].line).toBe(1)
|
||||
expect(data.references[2].path).toBe("src/b.ts")
|
||||
expect(data.references[2].line).toBe(3)
|
||||
})
|
||||
|
||||
it("should handle special regex characters in symbol", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const $value = 1", "$value + 2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "$value" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(2)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const x = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^find_references-\d+$/)
|
||||
})
|
||||
|
||||
it("should include execution time in result", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const x = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = createMockStorage()
|
||||
;(storage.getSymbolIndex as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Redis connection failed"),
|
||||
)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Redis connection failed")
|
||||
})
|
||||
|
||||
it("should trim symbol before searching", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const foo = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: " foo " }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.symbol).toBe("foo")
|
||||
expect(data.totalReferences).toBe(1)
|
||||
})
|
||||
|
||||
it("should handle empty files", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/empty.ts", createMockFileData([])],
|
||||
["src/test.ts", createMockFileData(["const x = 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "x" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(1)
|
||||
})
|
||||
|
||||
it("should handle symbols with underscores", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const my_variable = 1", "my_variable + 1"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "my_variable" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(2)
|
||||
})
|
||||
|
||||
it("should handle symbols with numbers", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const value1 = 1", "value1 + value2"])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "value1" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(2)
|
||||
})
|
||||
|
||||
it("should handle class method references", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
[
|
||||
"src/test.ts",
|
||||
createMockFileData([
|
||||
"class Foo {",
|
||||
" bar() {}",
|
||||
"}",
|
||||
"const f = new Foo()",
|
||||
"f.bar()",
|
||||
]),
|
||||
],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "bar" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(2)
|
||||
})
|
||||
|
||||
it("should not match partial words in strings", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["src/test.ts", createMockFileData(["const foo = 1", 'const msg = "foobar"'])],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
const ctx = createMockContext(storage)
|
||||
|
||||
const result = await tool.execute({ symbol: "foo" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as FindReferencesResult
|
||||
expect(data.totalReferences).toBe(1)
|
||||
expect(data.references[0].line).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,10 +11,10 @@ export default defineConfig({
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
lines: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
statements: 95,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user